Files
codex/codex-rs/tui/src/onboarding/auth.rs
T
smurching-oai 45d1f24d79 Support gateway device-code sign-in in headless TUI sessions (#60662)
## Why

Gateway sign-in needs a way to complete authentication over SSH or when no local browser is available.

## What changed

- Prefer device authentication for gateways that support it in SSH sessions, headless Linux environments, or when no browser handler is available.
- Check the selected session's provider and complete required gateway sign-in before requesting models, including when resuming a thread.
- Display the verification URL, one-time code, and cancellation instructions. Handle sign-in results, cancellation, and connection failures while the prompt is open.
- Skip device login for older servers that do not support it while preserving errors from current servers.

## Testing

Add protocol tests for early sign-in completion, connection loss, provider selection, resumed threads, and legacy-server compatibility, plus snapshots of the device prompt and gateway sign-in loading text.

GitOrigin-RevId: 4f3675ab2fc7fccafbad17796c72a7736ebb86b5
2026-11-10 21:09:59 +00:00

1924 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Authentication step UI and state transitions used by onboarding.
//!
//! This module owns the auth-step state machine (ChatGPT login/device-code/API
//! key), renders the corresponding UI, and handles auth-scoped keyboard input.
//! It intentionally does not decide onboarding flow completion; the enclosing
//! onboarding screen coordinates step progression.
#![allow(clippy::unwrap_used)]
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_login::AuthConfig;
use codex_login::read_openai_api_key_from_env;
use codex_protocol::auth::AuthMode;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_protocol::config_types::ForcedLoginMethod;
use std::cell::Cell;
use std::sync::Arc;
use std::sync::RwLock;
use std::time::Instant;
use uuid::Uuid;
use crate::LoginStatus;
use crate::activation_key_guard::ActivationKeyGuard;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::onboarding::bedrock::BedrockState;
use crate::onboarding::gateway::GatewayStep;
use crate::onboarding::gateway::USE_CUSTOM_PROVIDER;
use crate::onboarding::gcp::GcpState;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::visible_lines;
use crate::tui::FrameRequester;
/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.
///
/// Terminal emulators recognise the OSC 8 escape sequence and treat the entire
/// marked region as a single clickable link, regardless of row wrapping. This
/// is necessary because ratatui's cell-based rendering emits `MoveTo` at every
/// row boundary, which breaks normal terminal URL detection for long URLs that
/// wrap across multiple rows.
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
crate::terminal_hyperlinks::mark_url_hyperlink(buf, area, url);
}
use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
pub(super) use headless_chatgpt_login::render_device_code_prompt;
#[derive(Clone)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
#[allow(dead_code)]
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage,
ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState),
ApiKeyConfigured,
Bedrock(BedrockState),
BedrockConfigured,
Gcp(GcpState),
GcpConfigured,
GatewaySelected(String),
GatewayOAuth {
provider_id: String,
url: Option<String>,
},
GatewayOAuthFailed {
provider_id: String,
message: String,
},
GatewayConfigured,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SignInOption {
ChatGpt,
DeviceCode,
OtherMethods,
ApiKey,
Bedrock,
Gcp,
Gateway,
}
impl SignInOption {
fn is_alternative(self) -> bool {
!matches!(self, Self::ChatGpt | Self::DeviceCode | Self::OtherMethods)
}
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
pub(super) fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
pub(super) async fn cancel_login_attempt(
request_handle: &AppServerRequestHandle,
login_id: String,
) {
let _ = request_handle
.request_typed::<codex_app_server_protocol::CancelLoginAccountResponse>(
ClientRequest::CancelLoginAccount {
request_id: onboarding_request_id(),
params: CancelLoginAccountParams { login_id },
},
)
.await;
}
#[derive(Clone, Default)]
pub(crate) struct ApiKeyInputState {
value: String,
prepopulated_from_env: bool,
}
#[derive(Clone)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
pub(super) login_id: String,
pub(super) auth_url: String,
}
#[derive(Clone)]
pub(crate) struct ContinueWithDeviceCodeState {
request_id: String,
login_id: Option<String>,
verification_url: Option<String>,
user_code: Option<String>,
}
impl ContinueWithDeviceCodeState {
pub(crate) fn pending(request_id: String) -> Self {
Self {
request_id,
login_id: None,
verification_url: None,
user_code: None,
}
}
pub(crate) fn ready(
request_id: String,
login_id: String,
verification_url: String,
user_code: String,
) -> Self {
Self {
request_id,
login_id: Some(login_id),
verification_url: Some(verification_url),
user_code: Some(user_code),
}
}
pub(crate) fn login_id(&self) -> Option<&str> {
self.login_id.as_deref()
}
pub(crate) fn is_showing_copyable_auth(&self) -> bool {
self.verification_url
.as_deref()
.is_some_and(|url| !url.is_empty())
&& self
.user_code
.as_deref()
.is_some_and(|user_code| !user_code.is_empty())
}
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self
.activation_key_guard
.as_mut()
.is_some_and(|guard| guard.consume(KeyBinding::from_event(key_event), key_event.kind))
|| key_event.kind == KeyEventKind::Release
{
return;
}
if self.handle_gcp_key_event(&key_event)
|| self.handle_bedrock_key_event(&key_event)
|| self.handle_gateway_key_event(key_event)
{
return;
}
if self.handle_api_key_entry_key_event(&key_event) {
return;
}
if keys::MOVE_UP.is_pressed(key_event) {
self.move_highlight(/*delta*/ -1);
return;
}
if keys::MOVE_DOWN.is_pressed(key_event) {
self.move_highlight(/*delta*/ 1);
return;
}
if key_event.kind != KeyEventKind::Press {
return;
}
if keys::SELECT_FIRST.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 0, key_event);
return;
}
if keys::SELECT_SECOND.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 1, key_event);
return;
}
if keys::SELECT_THIRD.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 2, key_event);
return;
}
if keys::SELECT_FOURTH.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 3, key_event);
return;
}
if keys::CONFIRM.is_pressed(key_event) {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::GatewayOAuthFailed { provider_id, .. } => {
self.start_gateway_oauth(provider_id)
}
SignInState::PickMode => {
self.activate_highlighted_mode(key_event);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
}
return;
}
if keys::CANCEL.is_pressed(key_event) {
if matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode)
&& self.highlighted_mode.is_alternative()
{
self.highlighted_mode = SignInOption::OtherMethods;
self.set_error(/*message*/ None);
} else {
self.cancel_active_attempt();
}
}
}
fn handle_paste(&mut self, pasted: String) {
if let Some(wizard) = &mut self.gateway {
if let Some(input) = wizard.active_input_mut() {
input.paste(&pasted);
self.request_frame.schedule_frame();
}
return;
}
let mut state = self.sign_in_state.write().unwrap();
let input = match &mut *state {
SignInState::Bedrock(state) => state.active_input_mut(),
SignInState::Gcp(state) => state.active_input_mut(),
SignInState::ApiKeyEntry(_) => {
drop(state);
let _ = self.handle_api_key_entry_paste(pasted);
return;
}
_ => None,
};
if let Some(input) = input {
input.paste(&pasted);
self.set_error(/*message*/ None);
drop(state);
self.request_frame.schedule_frame();
}
}
}
#[allow(dead_code)]
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: SignInOption,
pub(super) activation_key_guard: Option<ActivationKeyGuard>,
pub error: Arc<RwLock<Option<String>>>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub login_status: LoginStatus,
pub app_server_target: crate::AppServerTarget,
pub app_server_request_handle: AppServerRequestHandle,
pub auth_config: AuthConfig,
pub bedrock_setup_enabled: bool,
pub gcp_setup_enabled: bool,
pub gateway_setup_enabled: bool,
pub(super) gateway: Option<GatewayStep>,
pub animations_enabled: bool,
pub animations_suppressed: Cell<bool>,
}
impl AuthModeWidget {
pub(crate) fn set_animations_suppressed(&self, suppressed: bool) {
self.animations_suppressed.set(suppressed);
}
pub(crate) fn should_suppress_animations(&self) -> bool {
matches!(
&*self.sign_in_state.read().unwrap(),
SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptDeviceCode(_)
)
}
pub(crate) fn cancel_active_attempt(&self) {
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(state) => {
let request_handle = self.app_server_request_handle.clone();
let login_id = state.login_id.clone();
tokio::spawn(async move {
cancel_login_attempt(&request_handle, login_id).await;
});
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(login_id) = state.login_id().map(str::to_owned) {
let request_handle = self.app_server_request_handle.clone();
tokio::spawn(async move {
cancel_login_attempt(&request_handle, login_id).await;
});
}
}
_ => return,
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
}
pub(super) fn set_error(&self, message: Option<String>) {
*self.error.write().unwrap() = message;
}
fn error_message(&self) -> Option<String> {
self.error.read().unwrap().clone()
}
/// Returns whether the auth flow is currently accepting text input.
pub(crate) fn is_text_entry_active(&self) -> bool {
if let Some(wizard) = &self.gateway {
return wizard.is_text_entry_active();
}
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(_) => true,
SignInState::Bedrock(state) => state.is_text_entry_active(),
SignInState::Gcp(state) => state.is_text_entry_active(),
_ => false,
})
}
/// Returns whether printable quit shortcuts must be treated as text input.
///
/// OpenAI API-key entry keeps its existing empty-field quit behavior, while
/// Cloud setup fields accept printable input from their first character.
pub(crate) fn should_suppress_printable_quit(&self) -> bool {
if let Some(wizard) = &self.gateway {
return wizard.is_text_entry_active();
}
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(state) => !state.value.is_empty(),
SignInState::Bedrock(state) => state.is_text_entry_active(),
SignInState::Gcp(state) => state.is_text_entry_active(),
_ => false,
})
}
fn confirm_binding(&self) -> KeyBinding {
keys::CONFIRM[0]
}
fn cancel_binding(&self) -> KeyBinding {
keys::CANCEL[0]
}
fn is_api_login_allowed(&self) -> bool {
self.auth_config
.is_login_method_allowed(ForcedLoginMethod::Api)
}
fn is_chatgpt_login_allowed(&self) -> bool {
self.auth_config
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
}
fn displayed_sign_in_options(&self) -> Vec<SignInOption> {
if self.highlighted_mode.is_alternative() {
let mut options = vec![SignInOption::ApiKey];
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
if self.gateway_setup_enabled {
options.push(SignInOption::Gateway);
}
return options;
}
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::OtherMethods);
}
options
}
fn move_highlight(&mut self, delta: isize) {
if !matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode) {
return;
}
let mut options = self.displayed_sign_in_options();
if !self.is_chatgpt_login_allowed() {
options.retain(|option| *option != SignInOption::ChatGpt);
}
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, key_event: KeyEvent) {
if !matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode) {
return;
}
let options = self.displayed_sign_in_options();
if let Some(option) = options.get(index).copied()
&& (option != SignInOption::ChatGpt || self.is_chatgpt_login_allowed())
{
self.highlighted_mode = option;
self.activate_highlighted_mode(key_event);
}
}
fn activate_highlighted_mode(&mut self, key_event: KeyEvent) {
let option = self.highlighted_mode;
if option == SignInOption::OtherMethods || option.is_alternative() {
self.guard_activation(key_event);
}
self.handle_sign_in_option(option);
}
pub(super) fn guard_activation(&mut self, key_event: KeyEvent) {
// Enter and printable shortcuts can repeat as plain presses without a release.
self.activation_key_guard = Some(ActivationKeyGuard::new_at(
KeyBinding::from_event(key_event),
Instant::now(),
/*reported_repeats_only*/ false,
));
}
fn handle_sign_in_option(&mut self, option: SignInOption) {
match option {
SignInOption::OtherMethods => {
self.highlighted_mode = SignInOption::ApiKey;
self.set_error(/*message*/ None);
}
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();
}
}
SignInOption::Bedrock => {
if self.bedrock_setup_enabled && self.is_api_login_allowed() {
self.start_bedrock_discovery();
} else if !self.is_api_login_allowed() {
self.disallow_api_login();
}
}
SignInOption::Gcp => {
if self.gcp_setup_enabled && self.is_api_login_allowed() {
*self.sign_in_state.write().unwrap() = SignInState::Gcp(GcpState::default());
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
} else if !self.is_api_login_allowed() {
self.disallow_api_login();
}
}
SignInOption::Gateway => {
if self.gateway_setup_enabled && self.is_api_login_allowed() {
self.start_gateway_picker();
} else if !self.is_api_login_allowed() {
self.disallow_api_login();
}
}
}
}
fn disallow_api_login(&mut self) {
self.highlighted_mode = SignInOption::ChatGpt;
self.set_error(Some(API_KEY_DISABLED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
}
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = if self.highlighted_mode.is_alternative() {
vec![" Sign in another way".into(), "".into()]
} else if self.bedrock_setup_enabled || self.gcp_setup_enabled || self.gateway_setup_enabled
{
vec![" Choose how you want to use Codex.".into(), "".into()]
} else {
vec![
Line::from(vec![
" ".into(),
"Sign in with ChatGPT to use Codex as part of your paid plan".into(),
]),
Line::from(vec![
" ".into(),
"or connect an API key for usage-based billing".into(),
]),
"".into(),
]
};
let options_start = lines.len();
let mut compact_options = Vec::new();
let mut create_mode_item = |idx: usize,
selected_mode: SignInOption,
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.highlighted_mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
let line1 = if is_selected {
Line::from(vec![
format!("{caret} {index}. ", index = idx + 1).cyan().dim(),
text.to_string().cyan(),
])
} else {
format!(" {index}. {text}", index = idx + 1).into()
};
let line2 = if is_selected {
Line::from(format!(" {description}"))
.fg(Color::Cyan)
.add_modifier(Modifier::DIM)
} else {
Line::from(format!(" {description}"))
.style(Style::default().add_modifier(Modifier::DIM))
};
compact_options.push(line1.clone());
vec![line1, line2]
};
let chatgpt_description = if !self.is_chatgpt_login_allowed() {
"ChatGPT login is disabled"
} else {
"Usage included with Plus, Pro, Business, and Enterprise plans"
};
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::OtherMethods => {
lines.extend(create_mode_item(
idx,
option,
"Sign in another way",
if self.bedrock_setup_enabled
|| self.gcp_setup_enabled
|| self.gateway_setup_enabled
{
"Use an API key or another provider"
} else {
"Use an OpenAI API key"
},
));
}
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,
"Use an OpenAI API key",
"Pay for what you use",
));
}
SignInOption::Bedrock => {
lines.extend(create_mode_item(
idx,
option,
"Use Amazon Bedrock",
"Connect using your AWS credentials",
));
}
SignInOption::Gcp => {
lines.extend(create_mode_item(
idx,
option,
"Use Google Cloud Vertex AI",
"Connect using your Google Cloud credentials",
));
}
SignInOption::Gateway => {
lines.extend(create_mode_item(
idx,
option,
USE_CUSTOM_PROVIDER,
"Choose a saved configuration or create a new one",
));
}
}
lines.push("".into());
}
let options_end = lines.len();
if !self.is_api_login_allowed() {
lines.push(
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
.dim()
.into(),
);
lines.push("".into());
}
lines.push(Line::from(vec![
" Press ".dim(),
self.confirm_binding().into(),
" to continue".dim(),
]));
if self.highlighted_mode.is_alternative() {
lines.push(Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to go back".dim(),
]));
}
if let Some(err) = self.error_message() {
lines.push("".into());
lines.push(err.red().into());
}
// Keep the options and confirmation hint visible in short terminals.
if Paragraph::new(lines.clone())
.wrap(Wrap { trim: false })
.line_count(area.width)
> usize::from(area.height)
{
lines.splice(options_start..options_end, compact_options);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_continue_in_browser(
&self,
area: Rect,
buf: &mut Buffer,
state: &ContinueInBrowserState,
) {
let mut spans = vec![" ".into()];
if self.animations_enabled && !self.animations_suppressed.get() {
// 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_text(
"Finish signing in via your browser",
MotionMode::Animated,
));
} else {
spans.push("Finish signing in via your browser".into());
}
let mut lines = vec![spans.into(), "".into()];
let auth_url = if !state.auth_url.is_empty() {
lines.push(Line::from(vec![
" If the link doesn't open automatically, press ".into(),
keys::COPY_LINK[0].into(),
" to copy it:".into(),
]));
if let Some(message) = self.error_message() {
lines.push(format!(" {message}").dim().into());
}
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
state.auth_url.as_str().cyan().underlined(),
]));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Press ".into(),
self.cancel_binding().into(),
" and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
lines.push("".into());
Some(state.auth_url.clone())
} else {
None
};
lines.push(Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to cancel".dim(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
// Wrap cyan+underlined URL cells with OSC 8 so the terminal treats
// the entire region as a single clickable hyperlink.
if let Some(url) = &auth_url {
mark_url_hyperlink(buf, area, url);
}
}
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let mut docs_line =
HyperlinkLine::new(Line::from(" Learn about permissions and approvals in the ").dim());
docs_line.push_span(
"Codex docs".underlined(),
Some("https://developers.openai.com/codex/security"),
);
let mut preferences_line =
HyperlinkLine::new(Line::from(" Your plan's rate limits and ").dim());
preferences_line.push_span(
"training data preferences".underlined(),
Some("https://chatgpt.com/#settings"),
);
preferences_line.push_span(" apply".into(), /*destination*/ None);
let lines = vec![
HyperlinkLine::new(
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
),
"".into(),
" You're in control".into(),
docs_line,
"".into(),
" Codex can make mistakes".into(),
HyperlinkLine::new(
" Review the code it writes and commands it runs"
.dim()
.into(),
),
"".into(),
" Included with your ChatGPT plan".into(),
preferences_line,
"".into(),
HyperlinkLine::new(Line::from(vec![
" Press ".fg(Color::Cyan),
self.confirm_binding().into(),
" to continue".fg(Color::Cyan),
])),
];
Paragraph::new(visible_lines(lines.clone()))
.wrap(Wrap { trim: false })
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &lines, /*scroll_rows*/ 0);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ API key configured".fg(Color::Green).into(),
"".into(),
" Codex will use usage-based billing with your API key.".into(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) {
let [intro_area, input_area, footer_area] = Layout::vertical([
Constraint::Min(4),
Constraint::Length(3),
Constraint::Min(2),
])
.areas(area);
let mut intro_lines: Vec<Line> = vec![
Line::from(vec![
"> ".into(),
"Use your own OpenAI API key for usage-based billing".bold(),
]),
"".into(),
" Paste or type your API key below.".into(),
"".into(),
];
if state.prepopulated_from_env {
intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into());
intro_lines.push(
" Paste a different key if you prefer to use another account."
.dim()
.into(),
);
intro_lines.push("".into());
}
Paragraph::new(intro_lines)
.wrap(Wrap { trim: false })
.render(intro_area, buf);
let content_line: Line = if state.value.is_empty() {
vec!["Paste or type your API key".dim()].into()
} else {
Line::from(state.value.clone())
};
Paragraph::new(content_line)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title("API key")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan)),
)
.render(input_area, buf);
let mut footer_lines: Vec<Line> = vec![
Line::from(vec![
" Press ".dim(),
self.confirm_binding().into(),
" to save".dim(),
]),
Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to go back".dim(),
]),
];
if let Some(error) = self.error_message() {
footer_lines.push("".into());
footer_lines.push(error.red().into());
}
Paragraph::new(footer_lines)
.wrap(Wrap { trim: false })
.render(footer_area, buf);
}
fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool {
let mut should_save: Option<String> = None;
let mut should_request_frame = false;
{
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
if keys::CANCEL.is_pressed(*key_event) {
*guard = SignInState::PickMode;
self.set_error(/*message*/ None);
should_request_frame = true;
} else if key_event.kind == KeyEventKind::Press
&& keys::CONFIRM.is_pressed(*key_event)
{
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.set_error(Some("API key cannot be empty".to_string()));
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
} else {
match key_event.code {
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
}
self.set_error(/*message*/ None);
should_request_frame = true;
}
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.set_error(/*message*/ None);
should_request_frame = true;
}
_ => {}
}
}
// handled; let guard drop before potential save
} else {
return false;
}
}
if let Some(api_key) = should_save {
self.save_api_key(api_key);
} else if should_request_frame {
self.request_frame.schedule_frame();
}
true
}
fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool {
let trimmed = pasted.trim();
if trimmed.is_empty() {
return false;
}
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
if state.prepopulated_from_env {
state.value = trimmed.to_string();
state.prepopulated_from_env = false;
} else {
state.value.push_str(trimmed);
}
self.set_error(/*message*/ None);
} else {
return false;
}
drop(guard);
self.request_frame.schedule_frame();
true
}
pub(super) fn start_api_key_entry(&mut self) {
if !self.is_api_login_allowed() {
self.disallow_api_login();
return;
}
self.set_error(/*message*/ None);
let prefill_from_env = read_openai_api_key_from_env();
let mut guard = self.sign_in_state.write().unwrap();
match &mut *guard {
SignInState::ApiKeyEntry(state) => {
if state.value.is_empty() {
if let Some(prefill) = prefill_from_env {
state.value = prefill;
state.prepopulated_from_env = true;
} else {
state.prepopulated_from_env = false;
}
}
}
_ => {
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
value: prefill_from_env.clone().unwrap_or_default(),
prepopulated_from_env: prefill_from_env.is_some(),
});
}
}
drop(guard);
self.request_frame.schedule_frame();
}
fn save_api_key(&mut self, api_key: String) {
if !self.is_api_login_allowed() {
self.disallow_api_login();
return;
}
self.set_error(/*message*/ None);
let request_handle = self.app_server_request_handle.clone();
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ApiKey {
api_key: api_key.clone(),
},
})
.await
{
Ok(LoginAccountResponse::ApiKey {}) => {
*error.write().unwrap() = None;
*sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
}
Ok(other) => {
*error.write().unwrap() = Some(format!(
"Unexpected account/login/start response: {other:?}"
));
*sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
Err(err) => {
*error.write().unwrap() = Some(format!("Failed to save API key: {err}"));
*sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
}
request_frame.schedule_frame();
});
self.request_frame.schedule_frame();
}
fn handle_existing_chatgpt_login(&mut self) -> bool {
if matches!(
self.login_status,
LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account()
) {
*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.
pub(super) 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;
}
self.set_error(/*message*/ None);
let app_server_target = self.app_server_target.clone();
let request_handle = self.app_server_request_handle.clone();
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
*error.write().unwrap() = None;
show_chatgpt_login_in_browser(
&app_server_target,
&sign_in_state,
&request_frame,
ContinueInBrowserState { login_id, auth_url },
webbrowser::open,
);
}
Ok(other) => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
*error.write().unwrap() = Some(format!(
"Unexpected account/login/start response: {other:?}"
));
}
Err(err) => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
*error.write().unwrap() = Some(err.to_string());
}
}
request_frame.schedule_frame();
});
}
fn start_device_code_login(&mut self) {
if self.handle_existing_chatgpt_login() {
return;
}
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
&mut self,
notification: AccountLoginCompletedNotification,
) {
let Some(login_id) = notification.login_id else {
return;
};
let guard = self.sign_in_state.read().unwrap();
let is_matching_login = matches!(
&*guard,
SignInState::ChatGptContinueInBrowser(state) if state.login_id == login_id
) || matches!(
&*guard,
SignInState::ChatGptDeviceCode(state) if state.login_id() == Some(login_id.as_str())
);
drop(guard);
if !is_matching_login {
return;
}
if notification.success {
self.set_error(/*message*/ None);
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
} else {
self.set_error(notification.error);
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
}
self.request_frame.schedule_frame();
}
pub(crate) fn on_account_updated(&mut self, notification: AccountUpdatedNotification) {
self.login_status = notification
.auth_mode
.map(|auth_mode| {
LoginStatus::AuthMode(match auth_mode {
ApiAuthMode::ApiKey => AuthMode::ApiKey,
ApiAuthMode::Chatgpt => AuthMode::Chatgpt,
ApiAuthMode::ChatgptAuthTokens => AuthMode::ChatgptAuthTokens,
ApiAuthMode::Headers => AuthMode::Headers,
ApiAuthMode::AgentIdentity => AuthMode::AgentIdentity,
ApiAuthMode::PersonalAccessToken => AuthMode::PersonalAccessToken,
ApiAuthMode::BedrockApiKey => AuthMode::BedrockApiKey,
ApiAuthMode::BedrockAccessKeys => AuthMode::BedrockAccessKeys,
})
})
.unwrap_or(LoginStatus::NotAuthenticated);
}
}
impl StepStateProvider for AuthModeWidget {
fn get_step_state(&self) -> StepState {
let sign_in_state = self.sign_in_state.read().unwrap();
match &*sign_in_state {
SignInState::PickMode
| SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage
| SignInState::Bedrock(_)
| SignInState::Gcp(_)
| SignInState::GatewayOAuth { .. }
| SignInState::GatewayOAuthFailed { .. } => StepState::InProgress,
SignInState::ChatGptSuccess
| SignInState::ApiKeyConfigured
| SignInState::BedrockConfigured
| SignInState::GcpConfigured
| SignInState::GatewaySelected(_)
| SignInState::GatewayConfigured => StepState::Complete,
}
}
}
impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if let Some(wizard) = &self.gateway {
wizard.render(area, buf);
return;
}
let sign_in_state = self.sign_in_state.read().unwrap();
match &*sign_in_state {
SignInState::PickMode => {
self.render_pick_mode(area, buf);
}
SignInState::ChatGptContinueInBrowser(state) => {
self.render_continue_in_browser(area, buf, state);
}
SignInState::ChatGptDeviceCode(state) => {
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
}
SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf);
}
SignInState::ChatGptSuccess => {
self.render_chatgpt_success(area, buf);
}
SignInState::ApiKeyEntry(state) => {
self.render_api_key_entry(area, buf, state);
}
SignInState::ApiKeyConfigured => {
self.render_api_key_configured(area, buf);
}
SignInState::Bedrock(state) => {
state.render(area, buf, self.error_message());
}
SignInState::Gcp(state) => {
state.render(area, buf, self.error_message());
}
SignInState::GatewayOAuth { url, .. } => {
let title = if url.is_some() {
"Finish signing in to your gateway in the browser."
} else {
"Starting gateway sign-in…"
};
let mut lines = vec![Line::from(title.bold())];
if let Some(url) = url {
lines.push(Line::from(url.clone().cyan().underlined()));
}
lines.push(Line::from("Press Ctrl+C to quit.".dim()));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
if let Some(url) = url {
mark_url_hyperlink(buf, area, url);
}
}
SignInState::GatewayOAuthFailed { message, .. } => {
Paragraph::new(vec![
Line::from(message.clone().red()),
Line::from("Press Enter to retry or Ctrl+C to quit.".dim()),
])
.wrap(Wrap { trim: false })
.render(area, buf);
}
SignInState::GatewaySelected(_) | SignInState::GatewayConfigured => {
Paragraph::new("✓ Custom provider configured".green())
.wrap(Wrap { trim: false })
.render(area, buf);
}
SignInState::GcpConfigured => {
Paragraph::new("✓ Google Cloud Vertex AI configured".green())
.wrap(Wrap { trim: false })
.render(area, buf);
}
SignInState::BedrockConfigured => {
Paragraph::new("✓ Amazon Bedrock configured".green())
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
}
}
fn show_chatgpt_login_in_browser(
target: &crate::AppServerTarget,
sign_in_state: &RwLock<SignInState>,
request_frame: &FrameRequester,
login: ContinueInBrowserState,
open: impl FnOnce(&str) -> std::io::Result<()>,
) {
let url = login.auth_url.clone();
*sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(login);
request_frame.schedule_frame();
// Local daemons use a remote request handle, but their callback is local.
if target.uses_remote_workspace() {
return;
}
if let Err(err) = open(&url) {
tracing::warn!("failed to open browser for login URL: {err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerEndpoint;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
const PRODUCTION_LENGTH_AUTH_URL: &str = concat!(
"https://auth.openai.com/oauth/authorize?",
"response_type=code&",
"client_id=app_EMoamEEZ73f0CkXaXp7hrann&",
"redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&",
"scope=openid%20profile%20email%20offline_access%20",
"api.connectors.read%20api.connectors.invoke&",
"code_challenge=1YM3Z8QbrLbdt9C3eX3j7UQ4GmFRmKz4OeVYwD6s5xA&",
"code_challenge_method=S256&",
"id_token_add_organizations=true&",
"codex_cli_simplified_flow=true&",
"state=8cHjQ4nVx2Yp7Lm9Rk3Wf6Ta1Bs5Du0Ei4Go7Nz2PqM&",
"originator=codex_cli_rs"
);
#[test]
fn opens_login_browser_for_local_app_servers() -> color_eyre::Result<()> {
let endpoint = RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
};
let url = "https://example.test/authorize";
for (target, expected) in [
(crate::AppServerTarget::Embedded, true),
(
crate::AppServerTarget::LocalDaemon {
endpoint: endpoint.clone(),
allow_embedded_fallback: true,
},
true,
),
(
crate::AppServerTarget::LocalDaemon {
endpoint: endpoint.clone(),
allow_embedded_fallback: false,
},
true,
),
(crate::AppServerTarget::Remote { endpoint }, false),
] {
let mut opened_url = None;
show_chatgpt_login_in_browser(
&target,
&RwLock::new(SignInState::PickMode),
&FrameRequester::test_dummy(),
ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: url.to_string(),
},
|url| {
opened_url = Some(url.to_owned());
Ok(())
},
);
assert_eq!(opened_url.as_deref(), expected.then_some(url));
}
Ok(())
}
#[tokio::test]
async fn login_can_complete_while_browser_is_opening() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
show_chatgpt_login_in_browser(
&crate::AppServerTarget::Embedded,
&sign_in_state,
&request_frame,
ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: "https://example.test/authorize".to_string(),
},
|_| {
widget.on_account_login_completed(AccountLoginCompletedNotification {
login_id: Some("login-1".to_string()),
success: true,
error: None,
onboarding_entrypoint: None,
});
Ok(())
},
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
pub(super) async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf();
// Embedded startup can refresh cloud config using the host's workload identity.
let loader_overrides = LoaderOverrides {
ignore_managed_requirements: true,
managed_config_path: Some(codex_home_path.join("managed_config.toml")),
..LoaderOverrides::without_managed_config_for_tests()
};
let config = ConfigBuilder::default()
.loader_overrides(loader_overrides.clone())
.codex_home(codex_home_path.clone())
.build()
.await
.unwrap();
let mut auth_config = config.auth_config();
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
required_human_approval: true,
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
embedded_network_policy: Default::default(),
feedback: codex_feedback::CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(
codex_app_server_client::EnvironmentManager::default_for_tests(),
),
config_warnings: Vec::new(),
session_source: serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
enable_codex_api_key_env: false,
client_name: "test".to_string(),
client_version: "test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await
.unwrap();
auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt);
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: SignInOption::ChatGpt,
activation_key_guard: None,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status: LoginStatus::NotAuthenticated,
app_server_target: crate::AppServerTarget::Embedded,
app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()),
auth_config,
bedrock_setup_enabled: false,
gcp_setup_enabled: false,
gateway_setup_enabled: false,
gateway: None,
animations_enabled: true,
animations_suppressed: std::cell::Cell::new(false),
};
(widget, codex_home)
}
#[tokio::test]
async fn api_key_entry_snapshots() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.auth_config.forced_login_method = None;
*widget.sign_in_state.write().unwrap() =
SignInState::ApiKeyEntry(ApiKeyInputState::default());
for (name, width) in [("api_key_entry", 80), ("api_key_entry_narrow", 40)] {
let height = 14;
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(
crate::test_backend::VT100Backend::new(width, height),
)
.expect("terminal");
terminal.set_viewport_area(area);
terminal
.draw(|frame| widget.render_ref(area, frame.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(name, terminal.backend().to_string());
}
}
#[tokio::test]
async fn api_key_flow_disabled_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.start_api_key_entry();
assert_eq!(
widget.error_message().as_deref(),
Some(API_KEY_DISABLED_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[tokio::test]
async fn bedrock_option_requires_feature_and_api_login_permission() {
let (mut widget, codex_home) = widget_forced_chatgpt().await;
widget.auth_config.forced_login_method = None;
assert_eq!(
widget.displayed_sign_in_options(),
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
SignInOption::OtherMethods,
]
);
std::fs::write(
codex_home.path().join("config.toml"),
"model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\n",
)
.unwrap();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.unwrap();
widget.bedrock_setup_enabled = crate::should_show_provider_setup_wizard(
widget.login_status,
/*requires_openai_auth*/ true,
&config,
&widget.app_server_target,
Feature::BedrockSetupWizard,
);
widget.handle_key_event(KeyCode::Char('3').into());
assert_eq!(
widget.displayed_sign_in_options(),
vec![SignInOption::ApiKey, SignInOption::Bedrock]
);
widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
widget.move_highlight(/*delta*/ 1);
let area = Rect::new(0, 0, 76, 19);
let mut buffer = Buffer::empty(area);
widget.render_pick_mode(area, &mut buffer);
let mut rows = (area.top()..area.bottom())
.map(|row| {
(area.left()..area.right())
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>();
while rows.last().is_some_and(String::is_empty) {
rows.pop();
}
insta::assert_snapshot!(rows.join("\n"), @r###"
Choose how you want to use Codex.
> 1. Sign in with ChatGPT
Usage included with Plus, Pro, Business, and Enterprise plans
2. Sign in with Device Code
Sign in from another device with a one-time code
3. Sign in another way
Use an API key or another provider
Press enter to continue
"###);
widget.auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt);
assert_eq!(
widget.displayed_sign_in_options(),
vec![SignInOption::ChatGpt, SignInOption::DeviceCode]
);
}
#[tokio::test]
async fn saving_api_key_is_blocked_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.save_api_key("sk-test".to_string());
assert_eq!(
widget.error_message().as_deref(),
Some(API_KEY_DISABLED_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() {
for auth_mode in [AuthMode::ChatgptAuthTokens, AuthMode::PersonalAccessToken] {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(auth_mode);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
}
#[tokio::test]
async fn cancel_active_attempt_resets_browser_login_state() {
let (widget, _tmp) = widget_forced_chatgpt().await;
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: "https://auth.example.com".to_string(),
});
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[tokio::test]
async fn cancel_active_attempt_notifies_device_code_login() {
let (widget, _tmp) = widget_forced_chatgpt().await;
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready(
"request-1".to_string(),
"login-1".to_string(),
"https://chatgpt.com/device".to_string(),
"ABCD-EFGH".to_string(),
));
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
let open = format!("\x1B]8;;{url}\x07");
let close = "\x1B]8;;\x07";
let mut chars = String::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let sym = buf[(x, y)].symbol();
if let Some(rest) = sym.strip_prefix(open.as_str())
&& let Some(ch) = rest.strip_suffix(close)
{
chars.push_str(ch);
}
}
}
chars
}
#[test]
fn continue_in_browser_preserves_long_link_and_footer_at_narrow_width() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
widget.set_animations_suppressed(/*suppressed*/ true);
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: PRODUCTION_LENGTH_AUTH_URL.to_string(),
});
let width = 44;
let height = 30;
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render_ref(area, &mut buf);
let found = collect_osc8_chars(&buf, area, PRODUCTION_LENGTH_AUTH_URL);
assert_eq!(
found, PRODUCTION_LENGTH_AUTH_URL,
"OSC 8 hyperlink should cover the full URL"
);
let mut terminal = crate::custom_terminal::Terminal::with_options(
crate::test_backend::VT100Backend::new(width, height),
)
.expect("terminal");
terminal.set_viewport_area(area);
terminal
.draw(|frame| widget.render_ref(area, frame.buffer_mut()))
.expect("draw");
let contents = terminal.backend().to_string();
insta::assert_snapshot!("continue_in_browser_narrow_long_url", contents);
assert!(contents.contains("On a remote or headless machine?"));
assert!(contents.contains("Press esc to cancel"));
}
#[test]
fn chatgpt_success_message_renders_osc8_hyperlinks() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area);
widget.render_chatgpt_success_message(area, &mut buf);
assert_eq!(
collect_osc8_chars(&buf, area, "https://developers.openai.com/codex/security"),
"Codex docs"
);
assert_eq!(
collect_osc8_chars(&buf, area, "https://chatgpt.com/#settings"),
"training data preferences"
);
assert_eq!(
(0..57).map(|x| buf[(x, 3)].modifier).collect::<Vec<_>>(),
[
vec![Modifier::DIM; 47],
vec![Modifier::DIM | Modifier::UNDERLINED; 10],
]
.concat()
);
assert_eq!(
(0..61).map(|x| buf[(x, 9)].modifier).collect::<Vec<_>>(),
[
vec![Modifier::DIM; 30],
vec![Modifier::DIM | Modifier::UNDERLINED; 25],
vec![Modifier::DIM; 6],
]
.concat()
);
let visible = (area.top()..area.bottom())
.map(|y| {
let row = (area.left()..area.right())
.map(|x| buf[(x, y)].symbol())
.collect::<String>();
crate::terminal_hyperlinks::strip_osc8(&row)
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(visible, @r###"
✓ Signed in with your ChatGPT account
You're in control
Learn about permissions and approvals in the Codex docs
Codex can make mistakes
Review the code it writes and commands it runs
Included with your ChatGPT plan
Your plan's rate limits and training data preferences apply
Press enter to continue
"###);
}
#[test]
fn auth_widget_suppresses_animations_when_device_code_is_visible() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready(
"request-1".to_string(),
"login-1".to_string(),
"https://chatgpt.com/device".to_string(),
"ABCD-EFGH".to_string(),
));
assert_eq!(widget.should_suppress_animations(), true);
}
#[test]
fn auth_widget_suppresses_animations_while_requesting_device_code() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
*widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(
ContinueWithDeviceCodeState::pending("request-1".to_string()),
);
assert_eq!(widget.should_suppress_animations(), true);
}
#[tokio::test]
async fn device_code_login_completion_advances_to_success_message() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready(
"request-1".to_string(),
"login-1".to_string(),
"https://chatgpt.com/device".to_string(),
"ABCD-EFGH".to_string(),
));
widget.on_account_login_completed(AccountLoginCompletedNotification {
login_id: Some("login-1".to_string()),
success: true,
error: None,
onboarding_entrypoint: None,
});
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
#[test]
fn mark_url_hyperlink_wraps_cyan_underlined_cells() {
let url = "https://example.com";
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
// Manually write some cyan+underlined characters to simulate a rendered URL.
for (i, ch) in "example".chars().enumerate() {
let cell = &mut buf[(i as u16, 0)];
cell.set_symbol(&ch.to_string());
cell.fg = Color::Cyan;
cell.modifier = Modifier::UNDERLINED;
}
// Leave a plain cell that should NOT be marked.
buf[(7, 0)].set_symbol("X");
mark_url_hyperlink(&mut buf, area, url);
// Each cyan+underlined cell should now carry the OSC 8 wrapper.
let found = collect_osc8_chars(&buf, area, url);
assert_eq!(found, "example");
// The plain "X" cell should be untouched.
assert_eq!(buf[(7, 0)].symbol(), "X");
}
#[test]
fn mark_url_hyperlink_sanitizes_control_chars() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
// One cyan+underlined cell to mark.
let cell = &mut buf[(0, 0)];
cell.set_symbol("a");
cell.fg = Color::Cyan;
cell.modifier = Modifier::UNDERLINED;
// URL contains ESC and BEL that could break the OSC 8 sequence.
let malicious_url = "https://evil.com/\x1B]8;;\x07injected";
mark_url_hyperlink(&mut buf, area, malicious_url);
let sym = buf[(0, 0)].symbol().to_string();
// The sanitized URL retains `]` (printable) but strips ESC and BEL.
let sanitized = "https://evil.com/]8;;injected";
assert!(
sym.contains(sanitized),
"symbol should contain sanitized URL, got: {sym:?}"
);
// The injected close-sequence must not survive: \x1B and \x07 are gone.
assert!(
!sym.contains("\x1B]8;;\x07injected"),
"symbol must not contain raw control chars from URL"
);
}
}
#[cfg(test)]
mod activation_key_tests;
#[cfg(test)]
#[path = "gcp_tests.rs"]
mod gcp_tests;
#[cfg(test)]
#[path = "gateway_tests.rs"]
mod gateway_tests;