[device-auth] When headless environment is detected, show device login flow instead. (#8756)

When headless environment is detected, show device login flow instead.
This commit is contained in:
Matthew Zeng authored and GitHub committed 2026-01-08 21:48:30 -08:00
1 parent d3ff668f68
commit 24d6e0114f
9 files changed
+1012 -42

No files matched your search

+81 -10
View File
@@ -14,6 +14,18 @@ use std::io::IsTerminal;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; 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( pub async fn login_with_chatgpt(
codex_home: PathBuf, codex_home: PathBuf,
forced_chatgpt_workspace_id: Option<String>, forced_chatgpt_workspace_id: Option<String>,
@@ -27,10 +39,7 @@ pub async fn login_with_chatgpt(
); );
let server = run_login_server(opts)?; let server = run_login_server(opts)?;
eprintln!( print_login_server_start(server.actual_port, &server.auth_url);
"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,
);
server.block_until_done().await 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; let config = load_config_or_exit(cli_config_overrides).await;
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { 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); std::process::exit(1);
} }
@@ -53,7 +62,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
.await .await
{ {
Ok(_) => { Ok(_) => {
eprintln!("Successfully logged in"); eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0); std::process::exit(0);
} }
Err(e) => { Err(e) => {
@@ -70,7 +79,7 @@ pub async fn run_login_with_api_key(
let config = load_config_or_exit(cli_config_overrides).await; let config = load_config_or_exit(cli_config_overrides).await;
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { 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); std::process::exit(1);
} }
@@ -80,7 +89,7 @@ pub async fn run_login_with_api_key(
config.cli_auth_credentials_store_mode, config.cli_auth_credentials_store_mode,
) { ) {
Ok(_) => { Ok(_) => {
eprintln!("Successfully logged in"); eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0); std::process::exit(0);
} }
Err(e) => { Err(e) => {
@@ -125,7 +134,7 @@ pub async fn run_login_with_device_code(
) -> ! { ) -> ! {
let config = load_config_or_exit(cli_config_overrides).await; let config = load_config_or_exit(cli_config_overrides).await;
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { 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); std::process::exit(1);
} }
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); 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 { match run_device_code_login(opts).await {
Ok(()) => { Ok(()) => {
eprintln!("Successfully logged in"); eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0); std::process::exit(0);
} }
Err(e) => { 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<String>,
client_id: Option<String>,
) -> ! {
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) -> ! { pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await; let config = load_config_or_exit(cli_config_overrides).await;
+9
View File
@@ -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_api_key;
use codex_cli::login::run_login_with_chatgpt; 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;
use codex_cli::login::run_login_with_device_code_fallback_to_browser;
use codex_cli::login::run_logout; use codex_cli::login::run_logout;
use codex_cloud_tasks::Cli as CloudTasksCli; use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_common::CliConfigOverrides; use codex_common::CliConfigOverrides;
use codex_core::env::is_headless_environment;
use codex_exec::Cli as ExecCli; use codex_exec::Cli as ExecCli;
use codex_exec::Command as ExecCommand; use codex_exec::Command as ExecCommand;
use codex_exec::ReviewArgs; use codex_exec::ReviewArgs;
@@ -531,6 +533,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
} else if login_cli.with_api_key { } else if login_cli.with_api_key {
let api_key = read_api_key_from_stdin(); let api_key = read_api_key_from_stdin();
run_login_with_api_key(login_cli.config_overrides, api_key).await; 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 { } else {
run_login_with_chatgpt(login_cli.config_overrides).await; run_login_with_chatgpt(login_cli.config_overrides).await;
} }
+27
View File
@@ -1,5 +1,9 @@
//! Functions for environment detection that need to be shared across crates. //! 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. /// Returns true if the current process is running under Windows Subsystem for Linux.
pub fn is_wsl() -> bool { pub fn is_wsl() -> bool {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -17,3 +21,26 @@ pub fn is_wsl() -> bool {
false 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
}
+43 -16
View File
@@ -14,6 +14,14 @@ const ANSI_BLUE: &str = "\x1b[94m";
const ANSI_GRAY: &str = "\x1b[90m"; const ANSI_GRAY: &str = "\x1b[90m";
const ANSI_RESET: &str = "\x1b[0m"; 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)] #[derive(Deserialize)]
struct UserCodeResp { struct UserCodeResp {
device_auth_id: String, device_auth_id: String,
@@ -73,7 +81,8 @@ async fn request_user_code(
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
if status == StatusCode::NOT_FOUND { 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.", "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!( println!(
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ "\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\ \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\ \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", \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 request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let issuer_base_url = opts.issuer.trim_end_matches('/'); let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{issuer_base_url}/api/accounts"); let api_base_url = format!("{base_url}/api/accounts");
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; 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( let code_resp = poll_for_token(
&client, &client,
&api_base_url, &api_base_url,
&uc.device_auth_id, &device_code.device_auth_id,
&uc.user_code, &device_code.user_code,
uc.interval, device_code.interval,
) )
.await?; .await?;
@@ -172,10 +192,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
code_verifier: code_resp.code_verifier, code_verifier: code_resp.code_verifier,
code_challenge: code_resp.code_challenge, 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( let tokens = crate::server::exchange_code_for_tokens(
issuer_base_url, base_url,
&opts.client_id, &opts.client_id,
&redirect_uri, &redirect_uri,
&pkce, &pkce,
@@ -201,3 +221,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
) )
.await .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
}
+3
View File
@@ -2,6 +2,9 @@ mod device_code_auth;
mod pkce; mod pkce;
mod server; 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 device_code_auth::run_device_code_login;
pub use server::LoginServer; pub use server::LoginServer;
pub use server::ServerOptions; pub use server::ServerOptions;
+48 -8
View File
@@ -5,6 +5,8 @@ use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID; use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env; 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::ServerOptions;
use codex_login::ShutdownHandle; use codex_login::ShutdownHandle;
use codex_login::run_login_server; use codex_login::run_login_server;
@@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Notify;
use super::onboarding_screen::StepState; use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
#[derive(Clone)] #[derive(Clone)]
pub(crate) enum SignInState { pub(crate) enum SignInState {
PickMode, PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage, ChatGptSuccessMessage,
ChatGptSuccess, ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState), ApiKeyEntry(ApiKeyInputState),
@@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState {
shutdown_flag: Option<ShutdownHandle>, shutdown_flag: Option<ShutdownHandle>,
} }
#[derive(Clone)]
pub(crate) struct ContinueWithDeviceCodeState {
device_code: Option<DeviceCode>,
cancel: Option<Arc<Notify>>,
}
impl Drop for ContinueInBrowserState { impl Drop for ContinueInBrowserState {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(handle) = &self.shutdown_flag { if let Some(handle) = &self.shutdown_flag {
@@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget {
} }
KeyCode::Esc => { KeyCode::Esc => {
tracing::info!("Esc pressed"); tracing::info!("Esc pressed");
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; let mut sign_in_state = self.sign_in_state.write().unwrap();
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { match &*sign_in_state {
*self.sign_in_state.write().unwrap() = SignInState::PickMode; SignInState::ChatGptContinueInBrowser(_) => {
self.request_frame.schedule_frame(); *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] vec![line1, line2]
}; };
let chatgpt_description = if self.is_chatgpt_login_allowed() { let chatgpt_description = if !self.is_chatgpt_login_allowed() {
"Usage included with Plus, Pro, Business, Education, and Enterprise plans"
} else {
"ChatGPT login is disabled" "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( lines.extend(create_mode_item(
0, 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(" If the link doesn't open automatically, open the following link to authenticate:".into());
lines.push("".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("".into());
lines.push(Line::from(vec![ lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(), " On a remote or headless machine? Use ".into(),
@@ -559,6 +588,7 @@ impl AuthModeWidget {
self.request_frame.schedule_frame(); 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) { fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login – // If we're already authenticated with ChatGPT, don't start a new login –
// just proceed to the success message flow. // just proceed to the success message flow.
@@ -575,6 +605,12 @@ impl AuthModeWidget {
self.forced_chatgpt_workspace_id.clone(), self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode, 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) { match run_login_server(opts) {
Ok(child) => { Ok(child) => {
let sign_in_state = self.sign_in_state.clone(); let sign_in_state = self.sign_in_state.clone();
@@ -623,6 +659,7 @@ impl StepStateProvider for AuthModeWidget {
SignInState::PickMode SignInState::PickMode
| SignInState::ApiKeyEntry(_) | SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress, | SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
} }
@@ -639,6 +676,9 @@ impl WidgetRef for AuthModeWidget {
SignInState::ChatGptContinueInBrowser(_) => { SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf); self.render_continue_in_browser(area, buf);
} }
SignInState::ChatGptDeviceCode(state) => {
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
}
SignInState::ChatGptSuccessMessage => { SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf); self.render_chatgpt_success_message(area, buf);
} }
@@ -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<Notify>) -> 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<RwLock<SignInState>>,
request_frame: &FrameRequester,
) -> Arc<Notify> {
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<RwLock<SignInState>>,
request_frame: &FrameRequester,
cancel: &Arc<Notify>,
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<RwLock<SignInState>>,
request_frame: &FrameRequester,
auth_manager: &AuthManager,
cancel: &Arc<Notify>,
) -> 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<Notify>) -> Arc<RwLock<SignInState>> {
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(_)
));
}
}
+47 -8
View File
@@ -5,6 +5,8 @@ use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID; use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env; 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::ServerOptions;
use codex_login::ShutdownHandle; use codex_login::ShutdownHandle;
use codex_login::run_login_server; use codex_login::run_login_server;
@@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Notify;
use super::onboarding_screen::StepState; use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
#[derive(Clone)] #[derive(Clone)]
pub(crate) enum SignInState { pub(crate) enum SignInState {
PickMode, PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage, ChatGptSuccessMessage,
ChatGptSuccess, ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState), ApiKeyEntry(ApiKeyInputState),
@@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState {
shutdown_flag: Option<ShutdownHandle>, shutdown_flag: Option<ShutdownHandle>,
} }
#[derive(Clone)]
pub(crate) struct ContinueWithDeviceCodeState {
device_code: Option<DeviceCode>,
cancel: Option<Arc<Notify>>,
}
impl Drop for ContinueInBrowserState { impl Drop for ContinueInBrowserState {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(handle) = &self.shutdown_flag { if let Some(handle) = &self.shutdown_flag {
@@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget {
} }
KeyCode::Esc => { KeyCode::Esc => {
tracing::info!("Esc pressed"); tracing::info!("Esc pressed");
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; let mut sign_in_state = self.sign_in_state.write().unwrap();
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { match &*sign_in_state {
*self.sign_in_state.write().unwrap() = SignInState::PickMode; SignInState::ChatGptContinueInBrowser(_) => {
self.request_frame.schedule_frame(); *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] vec![line1, line2]
}; };
let chatgpt_description = if self.is_chatgpt_login_allowed() { let chatgpt_description = if !self.is_chatgpt_login_allowed() {
"Usage included with Plus, Pro, Business, Education, and Enterprise plans"
} else {
"ChatGPT login is disabled" "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( lines.extend(create_mode_item(
0, 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(" If the link doesn't open automatically, open the following link to authenticate:".into());
lines.push("".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("".into());
lines.push(Line::from(vec![ lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(), " On a remote or headless machine? Use ".into(),
@@ -575,6 +604,12 @@ impl AuthModeWidget {
self.forced_chatgpt_workspace_id.clone(), self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode, 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) { match run_login_server(opts) {
Ok(child) => { Ok(child) => {
let sign_in_state = self.sign_in_state.clone(); let sign_in_state = self.sign_in_state.clone();
@@ -623,6 +658,7 @@ impl StepStateProvider for AuthModeWidget {
SignInState::PickMode SignInState::PickMode
| SignInState::ApiKeyEntry(_) | SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress, | SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
} }
@@ -639,6 +675,9 @@ impl WidgetRef for AuthModeWidget {
SignInState::ChatGptContinueInBrowser(_) => { SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf); self.render_continue_in_browser(area, buf);
} }
SignInState::ChatGptDeviceCode(state) => {
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
}
SignInState::ChatGptSuccessMessage => { SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf); self.render_chatgpt_success_message(area, buf);
} }
@@ -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<Notify>) -> 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<RwLock<SignInState>>,
request_frame: &FrameRequester,
) -> Arc<Notify> {
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<RwLock<SignInState>>,
request_frame: &FrameRequester,
cancel: &Arc<Notify>,
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<RwLock<SignInState>>,
request_frame: &FrameRequester,
auth_manager: &AuthManager,
cancel: &Arc<Notify>,
) -> 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<Notify>) -> Arc<RwLock<SignInState>> {
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(_)
));
}
}