From 00cce8721591cc1a1a9690a7f5df22d0c0203ac5 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 19 Dec 2025 15:37:45 -0800 Subject: [PATCH] update --- codex-rs/cli/src/login.rs | 61 ++++++ codex-rs/cli/src/main.rs | 31 +++ codex-rs/login/src/device_code_auth.rs | 49 ++++- codex-rs/login/src/lib.rs | 3 + codex-rs/tui/src/onboarding/auth.rs | 275 ++++++++++++++++++++++++- codex-rs/tui2/src/onboarding/auth.rs | 275 ++++++++++++++++++++++++- docs/authentication.md | 17 +- 7 files changed, 682 insertions(+), 29 deletions(-) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 8fbf7b04b6d..b5f3d144eec 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -150,6 +150,67 @@ pub async fn run_login_with_device_code( } } +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 is disabled. Use API key login instead."); + 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!("Successfully logged in"); + std::process::exit(0); + } + Err(e) => { + if e.to_string().contains("device code login is not enabled") { + eprintln!("Device code login is not enabled; falling back to browser login."); + match run_login_server(opts) { + Ok(server) => { + 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, + ); + match server.block_until_done().await { + Ok(()) => { + eprintln!("Successfully logged in"); + 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 80db64767d5..b9226d5a602 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,6 +14,7 @@ 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; @@ -533,6 +534,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; } @@ -773,6 +781,29 @@ fn print_completion(cmd: CompletionCommand) { generate(cmd.shell, &mut app, name, &mut std::io::stdout()); } +fn env_var_set(key: &str) -> bool { + std::env::var(key).is_ok_and(|v| !v.trim().is_empty()) +} + +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 +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index d9e7d90ce28..a56ffa56e28 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, @@ -137,33 +145,45 @@ async fn poll_for_token( } } -fn print_device_code_prompt(code: &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}https://auth.openai.com/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 ); } -/// 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 base_url = opts.issuer.trim_end_matches('/'); - let api_base_url = format!("{}/api/accounts", 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); + 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?; @@ -200,3 +220,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 6307e6e7dc9..f946b0d6bbe 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -5,8 +5,11 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; +use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -40,6 +43,7 @@ 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; @@ -47,6 +51,7 @@ use super::onboarding_screen::StepState; pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), + ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), @@ -68,6 +73,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 +139,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 +239,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, @@ -287,6 +312,58 @@ impl AuthModeWidget { .render(area, buf); } + fn render_device_code_login( + &self, + 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 self.animations_enabled { + // Schedule a follow-up frame to keep the shimmer animation going. + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); + spans.extend(shimmer_spans(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( + device_code.verification_url.as_str().cyan().underlined(), + )); + lines.push("".into()); + lines.push(" 2. Enter this one-time code (expires in 15 minutes)".into()); + lines.push("".into()); + lines.push(Line::from(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 render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ "✓ Signed in with your ChatGPT account".fg(Color::Green).into(), @@ -563,12 +640,167 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new( + let mut opts = ServerOptions::new( self.codex_home.clone(), CLIENT_ID.to_string(), self.forced_chatgpt_workspace_id.clone(), self.cli_auth_credentials_store_mode, ); + + if is_headless_environment() { + opts.open_browser = false; + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + let auth_manager = self.auth_manager.clone(); + let cancel = Arc::new(Notify::new()); + + *self.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + self.request_frame.schedule_frame(); + + tokio::spawn(async move { + let device_code = match request_device_code(&opts).await { + Ok(device_code) => device_code, + Err(err) => { + if err.to_string().contains("device code login is not enabled") { + let should_fallback = { + let guard = sign_in_state.read().unwrap(); + matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &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(_) => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + request_frame.schedule_frame(); + } + } + } else { + let mut guard = sign_in_state.write().unwrap(); + let should_reset = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_reset { + *guard = SignInState::PickMode; + drop(guard); + request_frame.schedule_frame(); + } + } + + return; + } + }; + + { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if !should_update { + return; + } + + *guard = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: Some(device_code.clone()), + cancel: Some(cancel.clone()), + }); + } + request_frame.schedule_frame(); + + tokio::select! { + _ = cancel.notified() => {} + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_update { + auth_manager.reload(); + *guard = SignInState::ChatGptSuccessMessage; + drop(guard); + request_frame.schedule_frame(); + } + } + Err(_) => { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_update { + *guard = SignInState::PickMode; + drop(guard); + request_frame.schedule_frame(); + } + } + } + } + } + }); + + return; + } + match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -617,6 +849,7 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) + | SignInState::ChatGptDeviceCode(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } @@ -633,6 +866,9 @@ impl WidgetRef for AuthModeWidget { SignInState::ChatGptContinueInBrowser(_) => { self.render_continue_in_browser(area, buf); } + SignInState::ChatGptDeviceCode(state) => { + self.render_device_code_login(area, buf, state); + } SignInState::ChatGptSuccessMessage => { self.render_chatgpt_success_message(area, buf); } @@ -649,6 +885,29 @@ impl WidgetRef for AuthModeWidget { } } +fn env_var_set(key: &str) -> bool { + std::env::var(key).is_ok_and(|v| !v.trim().is_empty()) +} + +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 +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 6307e6e7dc9..f946b0d6bbe 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -5,8 +5,11 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; +use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -40,6 +43,7 @@ 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; @@ -47,6 +51,7 @@ use super::onboarding_screen::StepState; pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), + ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), @@ -68,6 +73,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 +139,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 +239,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, @@ -287,6 +312,58 @@ impl AuthModeWidget { .render(area, buf); } + fn render_device_code_login( + &self, + 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 self.animations_enabled { + // Schedule a follow-up frame to keep the shimmer animation going. + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); + spans.extend(shimmer_spans(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( + device_code.verification_url.as_str().cyan().underlined(), + )); + lines.push("".into()); + lines.push(" 2. Enter this one-time code (expires in 15 minutes)".into()); + lines.push("".into()); + lines.push(Line::from(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 render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ "✓ Signed in with your ChatGPT account".fg(Color::Green).into(), @@ -563,12 +640,167 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new( + let mut opts = ServerOptions::new( self.codex_home.clone(), CLIENT_ID.to_string(), self.forced_chatgpt_workspace_id.clone(), self.cli_auth_credentials_store_mode, ); + + if is_headless_environment() { + opts.open_browser = false; + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + let auth_manager = self.auth_manager.clone(); + let cancel = Arc::new(Notify::new()); + + *self.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + self.request_frame.schedule_frame(); + + tokio::spawn(async move { + let device_code = match request_device_code(&opts).await { + Ok(device_code) => device_code, + Err(err) => { + if err.to_string().contains("device code login is not enabled") { + let should_fallback = { + let guard = sign_in_state.read().unwrap(); + matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &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(_) => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + request_frame.schedule_frame(); + } + } + } else { + let mut guard = sign_in_state.write().unwrap(); + let should_reset = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_reset { + *guard = SignInState::PickMode; + drop(guard); + request_frame.schedule_frame(); + } + } + + return; + } + }; + + { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if !should_update { + return; + } + + *guard = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: Some(device_code.clone()), + cancel: Some(cancel.clone()), + }); + } + request_frame.schedule_frame(); + + tokio::select! { + _ = cancel.notified() => {} + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_update { + auth_manager.reload(); + *guard = SignInState::ChatGptSuccessMessage; + drop(guard); + request_frame.schedule_frame(); + } + } + Err(_) => { + let mut guard = sign_in_state.write().unwrap(); + let should_update = matches!( + &*guard, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &cancel)) + ); + if should_update { + *guard = SignInState::PickMode; + drop(guard); + request_frame.schedule_frame(); + } + } + } + } + } + }); + + return; + } + match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -617,6 +849,7 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) + | SignInState::ChatGptDeviceCode(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } @@ -633,6 +866,9 @@ impl WidgetRef for AuthModeWidget { SignInState::ChatGptContinueInBrowser(_) => { self.render_continue_in_browser(area, buf); } + SignInState::ChatGptDeviceCode(state) => { + self.render_device_code_login(area, buf, state); + } SignInState::ChatGptSuccessMessage => { self.render_chatgpt_success_message(area, buf); } @@ -649,6 +885,29 @@ impl WidgetRef for AuthModeWidget { } } +fn env_var_set(key: &str) -> bool { + std::env::var(key).is_ok_and(|v| !v.trim().is_empty()) +} + +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 +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/authentication.md b/docs/authentication.md index 617161f6488..e71a09f17b4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,7 +28,18 @@ If you've used the Codex CLI before with usage-based billing via an API key and ## Connecting on a "Headless" Machine -Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds: +Codex supports two ChatGPT login flows: + +- **Device code login** (recommended for headless): shows a one-time code in your terminal. Open the verification URL in any browser and enter the code. +- **Browser login**: starts a local server on `localhost:1455` and completes login via a browser redirect. + +On headless machines (SSH, Docker, CI), Codex will automatically prefer device code login when you sign in with ChatGPT. You can also start it explicitly with: + +```shell +codex login --device-auth +``` + +If device code login isn’t available for your environment/server, use one of the following workarounds for the browser login flow: ### Authenticate locally and copy your credentials to the "headless" machine @@ -65,4 +76,6 @@ If you run Codex on a remote machine (VPS/server) without a local browser, the l ssh -L 1455:localhost:1455 @ ``` -Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server. +Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL in your local browser. The traffic will be tunneled to the remote server. + +If you see a device code prompt instead, your environment was detected as headless and device code login was selected automatically.