Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
212 changes: 151 additions & 61 deletions codex-rs/tui/src/onboarding/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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>> {
Expand All @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -588,13 +660,21 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}

fn handle_existing_chatgpt_login(&mut self) -> bool {
Copy link
Collaborator

@joshka-oai joshka-oai Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: A bool return value isn't really all that useful without documenting its meaning.

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;
}

Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions codex-rs/tui/src/onboarding/onboarding_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading