[device-auth] Add device code auth as a standalone option when headless environment is detected. (#9333)

This commit is contained in:
Matthew Zeng
2026-01-16 08:35:03 -08:00
committed by GitHub
parent 2691e1ce21
commit 131590066e
5 changed files with 308 additions and 137 deletions

View File

@@ -14,11 +14,9 @@ use codex_cli::login::run_login_status;
use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_login_with_device_code;
use codex_cli::login::run_login_with_device_code_fallback_to_browser;
use codex_cli::login::run_logout;
use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_common::CliConfigOverrides;
use codex_core::env::is_headless_environment;
use codex_exec::Cli as ExecCli;
use codex_exec::Command as ExecCommand;
use codex_exec::ReviewArgs;
@@ -600,13 +598,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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;
}

View File

@@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env;
use codex_core::env::is_headless_environment;
use codex_login::DeviceCode;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
@@ -59,6 +58,13 @@ pub(crate) enum SignInState {
ApiKeyConfigured,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SignInOption {
ChatGpt,
DeviceCode,
ApiKey,
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
#[derive(Clone, Default)]
@@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.is_chatgpt_login_allowed() {
self.highlighted_mode = AuthMode::ChatGPT;
}
self.move_highlight(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
if self.is_api_login_allowed() {
self.highlighted_mode = AuthMode::ApiKey;
}
self.move_highlight(1);
}
KeyCode::Char('1') => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
}
self.select_option_by_index(0);
}
KeyCode::Char('2') => {
if self.is_api_login_allowed() {
self.start_api_key_entry();
} else {
self.disallow_api_login();
}
self.select_option_by_index(1);
}
KeyCode::Char('3') => {
self.select_option_by_index(2);
}
KeyCode::Enter => {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => match self.highlighted_mode {
AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => {
self.start_chatgpt_login();
}
AuthMode::ApiKey if self.is_api_login_allowed() => {
self.start_api_key_entry();
}
AuthMode::ChatGPT => {}
AuthMode::ApiKey => {
self.disallow_api_login();
}
},
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
@@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget {
#[derive(Clone)]
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: AuthMode,
pub highlighted_mode: SignInOption,
pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
@@ -191,8 +181,75 @@ impl AuthModeWidget {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Api))
}
fn displayed_sign_in_options(&self) -> Vec<SignInOption> {
let mut options = vec![SignInOption::ChatGpt];
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
}
options
}
fn selectable_sign_in_options(&self) -> Vec<SignInOption> {
let mut options = Vec::new();
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::ChatGpt);
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
}
options
}
fn move_highlight(&mut self, delta: isize) {
let options = self.selectable_sign_in_options();
if options.is_empty() {
return;
}
let current_index = options
.iter()
.position(|option| *option == self.highlighted_mode)
.unwrap_or(0);
let next_index =
(current_index as isize + delta).rem_euclid(options.len() as isize) as usize;
self.highlighted_mode = options[next_index];
}
fn select_option_by_index(&mut self, index: usize) {
let options = self.displayed_sign_in_options();
if let Some(option) = options.get(index).copied() {
self.handle_sign_in_option(option);
}
}
fn handle_sign_in_option(&mut self, option: SignInOption) {
match option {
SignInOption::ChatGpt => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
}
}
SignInOption::DeviceCode => {
if self.is_chatgpt_login_allowed() {
self.start_device_code_login();
}
}
SignInOption::ApiKey => {
if self.is_api_login_allowed() {
self.start_api_key_entry();
} else {
self.disallow_api_login();
}
}
}
}
fn disallow_api_login(&mut self) {
self.highlighted_mode = AuthMode::ChatGPT;
self.highlighted_mode = SignInOption::ChatGpt;
self.error = Some(API_KEY_DISABLED_MESSAGE.to_string());
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
@@ -212,7 +269,7 @@ impl AuthModeWidget {
];
let create_mode_item = |idx: usize,
selected_mode: AuthMode,
selected_mode: SignInOption,
text: &str,
description: &str|
-> Vec<Line<'static>> {
@@ -221,11 +278,11 @@ impl AuthModeWidget {
let line1 = if is_selected {
Line::from(vec![
format!("{} {}. ", caret, idx + 1).cyan().dim(),
format!("{caret} {index}. ", index = idx + 1).cyan().dim(),
text.to_string().cyan(),
])
} else {
format!(" {}. {text}", idx + 1).into()
format!(" {index}. {text}", index = idx + 1).into()
};
let line2 = if is_selected {
@@ -242,27 +299,42 @@ impl AuthModeWidget {
let chatgpt_description = if !self.is_chatgpt_login_allowed() {
"ChatGPT login is disabled"
} else if is_headless_environment() {
"Uses device code login (headless environment detected)"
} else {
"Usage included with Plus, Pro, Team, and Enterprise plans"
};
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT",
chatgpt_description,
));
lines.push("".into());
if self.is_api_login_allowed() {
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay for what you use",
));
let device_code_description = "Sign in from another device with a one-time code";
for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() {
match option {
SignInOption::ChatGpt => {
lines.extend(create_mode_item(
idx,
option,
"Sign in with ChatGPT",
chatgpt_description,
));
}
SignInOption::DeviceCode => {
lines.extend(create_mode_item(
idx,
option,
"Sign in with Device Code",
device_code_description,
));
}
SignInOption::ApiKey => {
lines.extend(create_mode_item(
idx,
option,
"Provide your own API key",
"Pay for what you use",
));
}
}
lines.push("".into());
} else {
}
if !self.is_api_login_allowed() {
lines.push(
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
.dim()
@@ -309,9 +381,9 @@ impl AuthModeWidget {
]));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(),
"codex login --device-auth".cyan(),
" instead".into(),
" On a remote or headless machine? Press Esc and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
lines.push("".into());
}
@@ -588,13 +660,21 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
fn handle_existing_chatgpt_login(&mut self) -> bool {
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
true
} else {
false
}
}
/// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt.
fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login
// just proceed to the success message flow.
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
if self.handle_existing_chatgpt_login() {
return;
}
@@ -606,11 +686,6 @@ impl AuthModeWidget {
self.cli_auth_credentials_store_mode,
);
if is_headless_environment() {
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
return;
}
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
@@ -650,6 +725,21 @@ impl AuthModeWidget {
}
}
}
fn start_device_code_login(&mut self) {
if self.handle_existing_chatgpt_login() {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
}
}
impl StepStateProvider for AuthModeWidget {
@@ -708,7 +798,7 @@ mod tests {
let codex_home_path = codex_home.path().to_path_buf();
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: AuthMode::ChatGPT,
highlighted_mode: SignInOption::ChatGpt,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(),

View File

@@ -11,11 +11,11 @@ use ratatui::style::Color;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
@@ -92,8 +92,8 @@ impl OnboardingScreen {
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => AuthMode::ApiKey,
_ => AuthMode::ChatGPT,
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),

View File

@@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env;
use codex_core::env::is_headless_environment;
use codex_login::DeviceCode;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
@@ -59,6 +58,13 @@ pub(crate) enum SignInState {
ApiKeyConfigured,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SignInOption {
ChatGpt,
DeviceCode,
ApiKey,
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
#[derive(Clone, Default)]
@@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.is_chatgpt_login_allowed() {
self.highlighted_mode = AuthMode::ChatGPT;
}
self.move_highlight(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
if self.is_api_login_allowed() {
self.highlighted_mode = AuthMode::ApiKey;
}
self.move_highlight(1);
}
KeyCode::Char('1') => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
}
self.select_option_by_index(0);
}
KeyCode::Char('2') => {
if self.is_api_login_allowed() {
self.start_api_key_entry();
} else {
self.disallow_api_login();
}
self.select_option_by_index(1);
}
KeyCode::Char('3') => {
self.select_option_by_index(2);
}
KeyCode::Enter => {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => match self.highlighted_mode {
AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => {
self.start_chatgpt_login();
}
AuthMode::ApiKey if self.is_api_login_allowed() => {
self.start_api_key_entry();
}
AuthMode::ChatGPT => {}
AuthMode::ApiKey => {
self.disallow_api_login();
}
},
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
@@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget {
#[derive(Clone)]
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: AuthMode,
pub highlighted_mode: SignInOption,
pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
@@ -191,8 +181,75 @@ impl AuthModeWidget {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Api))
}
fn displayed_sign_in_options(&self) -> Vec<SignInOption> {
let mut options = vec![SignInOption::ChatGpt];
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
}
options
}
fn selectable_sign_in_options(&self) -> Vec<SignInOption> {
let mut options = Vec::new();
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::ChatGpt);
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
}
options
}
fn move_highlight(&mut self, delta: isize) {
let options = self.selectable_sign_in_options();
if options.is_empty() {
return;
}
let current_index = options
.iter()
.position(|option| *option == self.highlighted_mode)
.unwrap_or(0);
let next_index =
(current_index as isize + delta).rem_euclid(options.len() as isize) as usize;
self.highlighted_mode = options[next_index];
}
fn select_option_by_index(&mut self, index: usize) {
let options = self.displayed_sign_in_options();
if let Some(option) = options.get(index).copied() {
self.handle_sign_in_option(option);
}
}
fn handle_sign_in_option(&mut self, option: SignInOption) {
match option {
SignInOption::ChatGpt => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
}
}
SignInOption::DeviceCode => {
if self.is_chatgpt_login_allowed() {
self.start_device_code_login();
}
}
SignInOption::ApiKey => {
if self.is_api_login_allowed() {
self.start_api_key_entry();
} else {
self.disallow_api_login();
}
}
}
}
fn disallow_api_login(&mut self) {
self.highlighted_mode = AuthMode::ChatGPT;
self.highlighted_mode = SignInOption::ChatGpt;
self.error = Some(API_KEY_DISABLED_MESSAGE.to_string());
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
@@ -212,7 +269,7 @@ impl AuthModeWidget {
];
let create_mode_item = |idx: usize,
selected_mode: AuthMode,
selected_mode: SignInOption,
text: &str,
description: &str|
-> Vec<Line<'static>> {
@@ -221,11 +278,11 @@ impl AuthModeWidget {
let line1 = if is_selected {
Line::from(vec![
format!("{} {}. ", caret, idx + 1).cyan().dim(),
format!("{caret} {index}. ", index = idx + 1).cyan().dim(),
text.to_string().cyan(),
])
} else {
format!(" {}. {text}", idx + 1).into()
format!(" {index}. {text}", index = idx + 1).into()
};
let line2 = if is_selected {
@@ -242,27 +299,42 @@ impl AuthModeWidget {
let chatgpt_description = if !self.is_chatgpt_login_allowed() {
"ChatGPT login is disabled"
} else if is_headless_environment() {
"Uses device code login (headless environment detected)"
} else {
"Usage included with Plus, Pro, Team, and Enterprise plans"
};
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT",
chatgpt_description,
));
lines.push("".into());
if self.is_api_login_allowed() {
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay for what you use",
));
let device_code_description = "Sign in from another device with a one-time code";
for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() {
match option {
SignInOption::ChatGpt => {
lines.extend(create_mode_item(
idx,
option,
"Sign in with ChatGPT",
chatgpt_description,
));
}
SignInOption::DeviceCode => {
lines.extend(create_mode_item(
idx,
option,
"Sign in with Device Code",
device_code_description,
));
}
SignInOption::ApiKey => {
lines.extend(create_mode_item(
idx,
option,
"Provide your own API key",
"Pay for what you use",
));
}
}
lines.push("".into());
} else {
}
if !self.is_api_login_allowed() {
lines.push(
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
.dim()
@@ -309,9 +381,9 @@ impl AuthModeWidget {
]));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Use ".into(),
"codex login --device-auth".cyan(),
" instead".into(),
" On a remote or headless machine? Press Esc and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
lines.push("".into());
}
@@ -588,12 +660,20 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login
// just proceed to the success message flow.
fn handle_existing_chatgpt_login(&mut self) -> bool {
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
true
} else {
false
}
}
fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login
// just proceed to the success message flow.
if self.handle_existing_chatgpt_login() {
return;
}
@@ -605,11 +685,6 @@ impl AuthModeWidget {
self.cli_auth_credentials_store_mode,
);
if is_headless_environment() {
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
return;
}
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
@@ -649,6 +724,21 @@ impl AuthModeWidget {
}
}
}
fn start_device_code_login(&mut self) {
if self.handle_existing_chatgpt_login() {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
}
}
impl StepStateProvider for AuthModeWidget {
@@ -707,7 +797,7 @@ mod tests {
let codex_home_path = codex_home.path().to_path_buf();
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: AuthMode::ChatGPT,
highlighted_mode: SignInOption::ChatGpt,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(),

View File

@@ -11,11 +11,11 @@ use ratatui::style::Color;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
@@ -92,8 +92,8 @@ impl OnboardingScreen {
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => AuthMode::ApiKey,
_ => AuthMode::ChatGPT,
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),