mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## 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
1924 lines
69 KiB
Rust
1924 lines
69 KiB
Rust
//! 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;
|