This commit is contained in:
Eric Traut
2026-03-14 11:03:56 -06:00
parent 18fcca6ebe
commit cd05014c76
7 changed files with 599 additions and 666 deletions

View File

@@ -13,7 +13,6 @@ use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
use codex_core::ThreadSortKey;
@@ -227,6 +226,7 @@ mod wrapping;
#[cfg(test)]
pub mod test_backend;
use crate::onboarding::account_login::read_login_status_via_app_server;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui::Tui;
@@ -663,12 +663,44 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let auth_manager = AuthManager::shared(
initial_config.codex_home.clone(),
false,
initial_config.cli_auth_credentials_store_mode,
);
let login_status = get_login_status(&initial_config);
let mut onboarding_app_server = if initial_config.model_provider.requires_openai_auth {
Some(
match start_embedded_app_server(
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await
{
Ok(app_server) => app_server,
Err(err) => {
restore();
session_log::log_session_end();
return Err(err);
}
},
)
} else {
None
};
let login_status = if let Some(app_server) = onboarding_app_server.as_ref() {
match read_login_status_via_app_server(app_server).await {
Ok(status) => status,
Err(err) => {
if let Some(app_server) = onboarding_app_server.take() {
let _ = app_server.shutdown().await;
}
restore();
session_log::log_session_end();
return Err(color_eyre::eyre::eyre!("{err}"));
}
}
} else {
LoginStatus::NotAuthenticated
};
let should_show_trust_screen_flag = should_show_trust_screen(&initial_config);
let should_show_onboarding =
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
@@ -681,12 +713,21 @@ async fn run_ratatui_app(
show_login_screen,
show_trust_screen: should_show_trust_screen_flag,
login_status,
auth_manager: auth_manager.clone(),
config: initial_config.clone(),
},
&mut tui,
onboarding_app_server
.as_mut()
.and_then(|client| show_login_screen.then_some(client)),
)
.await?;
.await;
if let Some(app_server) = onboarding_app_server.take() {
app_server
.shutdown()
.await
.wrap_err("failed to shut down onboarding app server")?;
}
let onboarding_result = onboarding_result?;
if onboarding_result.should_exit {
restore();
session_log::log_session_end();
@@ -705,7 +746,11 @@ async fn run_ratatui_app(
// status detection edge cases.
if show_login_screen {
cloud_requirements = cloud_requirements_loader(
auth_manager.clone(),
AuthManager::shared(
initial_config.codex_home.clone(),
false,
initial_config.cli_auth_credentials_store_mode,
),
initial_config.chatgpt_base_url.clone(),
initial_config.codex_home.clone(),
);
@@ -724,6 +769,12 @@ async fn run_ratatui_app(
initial_config
}
} else {
if let Some(app_server) = onboarding_app_server.take() {
app_server
.shutdown()
.await
.wrap_err("failed to shut down onboarding app server")?;
}
initial_config
};
@@ -1198,24 +1249,6 @@ pub enum LoginStatus {
NotAuthenticated,
}
fn get_login_status(config: &Config) -> LoginStatus {
if config.model_provider.requires_openai_auth {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.auth_mode()),
Ok(None) => LoginStatus::NotAuthenticated,
Err(err) => {
error!("Failed to read auth.json: {err}");
LoginStatus::NotAuthenticated
}
}
} else {
LoginStatus::NotAuthenticated
}
}
async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,

View File

@@ -0,0 +1,126 @@
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthMode;
use serde::de::DeserializeOwned;
use crate::LoginStatus;
pub(crate) enum AuthCommand {
StartApiKey { api_key: String },
StartChatgpt,
CancelChatgpt { login_id: String },
}
#[derive(Default)]
pub(crate) struct OnboardingAccountApi {
next_request_id: i64,
}
impl OnboardingAccountApi {
pub(crate) async fn read_account(
&mut self,
app_server: &InProcessAppServerClient,
) -> Result<GetAccountResponse, String> {
send_request_with_response(
app_server,
ClientRequest::GetAccount {
request_id: self.next_request_id(),
params: GetAccountParams {
refresh_token: false,
},
},
"account/read",
)
.await
}
pub(crate) async fn start_api_key_login(
&mut self,
app_server: &InProcessAppServerClient,
api_key: String,
) -> Result<LoginAccountResponse, String> {
send_request_with_response(
app_server,
ClientRequest::LoginAccount {
request_id: self.next_request_id(),
params: LoginAccountParams::ApiKey { api_key },
},
"account/login/start",
)
.await
}
pub(crate) async fn start_chatgpt_login(
&mut self,
app_server: &InProcessAppServerClient,
) -> Result<LoginAccountResponse, String> {
send_request_with_response(
app_server,
ClientRequest::LoginAccount {
request_id: self.next_request_id(),
params: LoginAccountParams::Chatgpt,
},
"account/login/start",
)
.await
}
pub(crate) async fn cancel_chatgpt_login(
&mut self,
app_server: &InProcessAppServerClient,
login_id: String,
) -> Result<CancelLoginAccountResponse, String> {
send_request_with_response(
app_server,
ClientRequest::CancelLoginAccount {
request_id: self.next_request_id(),
params: CancelLoginAccountParams { login_id },
},
"account/login/cancel",
)
.await
}
fn next_request_id(&mut self) -> RequestId {
self.next_request_id += 1;
RequestId::Integer(self.next_request_id)
}
}
pub(crate) fn login_status_from_account(account: Option<&Account>) -> LoginStatus {
match account {
Some(Account::ApiKey {}) => LoginStatus::AuthMode(AuthMode::ApiKey),
Some(Account::Chatgpt { .. }) => LoginStatus::AuthMode(AuthMode::Chatgpt),
None => LoginStatus::NotAuthenticated,
}
}
pub(crate) async fn read_login_status_via_app_server(
app_server: &InProcessAppServerClient,
) -> Result<LoginStatus, String> {
let mut api = OnboardingAccountApi::default();
let response = api.read_account(app_server).await?;
Ok(login_status_from_account(response.account.as_ref()))
}
async fn send_request_with_response<T>(
app_server: &InProcessAppServerClient,
request: ClientRequest,
method: &str,
) -> Result<T, String>
where
T: DeserializeOwned,
{
app_server
.request_typed(request)
.await
.map_err(|err| format!("{method} failed: {err}"))
}

View File

@@ -1,14 +1,9 @@
#![allow(clippy::unwrap_used)]
use codex_core::AuthManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_core::auth::AuthMode;
use codex_core::auth::read_openai_api_key_from_env;
use codex_login::DeviceCode;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -29,10 +24,11 @@ use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_core::auth::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::mpsc::UnboundedSender;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
@@ -40,10 +36,14 @@ use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::account_login::AuthCommand;
use super::account_login::login_status_from_account;
use super::onboarding_screen::StepState;
/// 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
/// 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.
@@ -62,7 +62,6 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buf[(x, y)];
// Only mark cells that carry the URL's distinctive style.
if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) {
continue;
}
@@ -74,19 +73,11 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
}
}
}
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
#[derive(Clone)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage,
ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState),
@@ -101,6 +92,7 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const DEVICE_CODE_UNAVAILABLE_MESSAGE: &str = "Device code sign-in is not yet available through the app-server onboarding flow. Use browser sign-in instead.";
#[derive(Clone, Default)]
pub(crate) struct ApiKeyInputState {
@@ -109,24 +101,9 @@ pub(crate) struct ApiKeyInputState {
}
#[derive(Clone)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
auth_url: String,
shutdown_flag: Option<ShutdownHandle>,
}
#[derive(Clone)]
pub(crate) struct ContinueWithDeviceCodeState {
device_code: Option<DeviceCode>,
cancel: Option<Arc<Notify>>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(handle) = &self.shutdown_flag {
handle.shutdown();
}
}
login_id: Option<String>,
}
impl KeyboardHandler for AuthModeWidget {
@@ -166,21 +143,15 @@ impl KeyboardHandler for AuthModeWidget {
KeyCode::Esc => {
tracing::info!("Esc pressed");
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(_) => {
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state {
if let Some(login_id) = state.login_id.clone() {
let _ = self
.auth_command_tx
.send(AuthCommand::CancelChatgpt { login_id });
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
_ => {}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
}
_ => {}
@@ -194,15 +165,12 @@ impl KeyboardHandler for AuthModeWidget {
#[derive(Clone)]
pub(crate) struct AuthModeWidget {
pub auth_command_tx: UnboundedSender<AuthCommand>,
pub request_frame: FrameRequester,
pub highlighted_mode: SignInOption,
pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
@@ -270,7 +238,7 @@ impl AuthModeWidget {
}
SignInOption::DeviceCode => {
if self.is_chatgpt_login_allowed() {
self.start_device_code_login();
self.show_device_code_login_todo();
}
}
SignInOption::ApiKey => {
@@ -290,6 +258,14 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
fn show_device_code_login_todo(&mut self) {
// TODO: Restore device-code onboarding once app-server exposes a typed
// login flow for it.
self.error = Some(DEVICE_CODE_UNAVAILABLE_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> = vec![
Line::from(vec![
@@ -313,11 +289,11 @@ impl AuthModeWidget {
let line1 = if is_selected {
Line::from(vec![
format!("{caret} {index}. ", index = idx + 1).cyan().dim(),
format!("{caret} {}. ", idx + 1).cyan().dim(),
text.to_string().cyan(),
])
} else {
format!(" {index}. {text}", index = idx + 1).into()
format!(" {}. {text}", idx + 1).into()
};
let line2 = if is_selected {
@@ -377,11 +353,7 @@ impl AuthModeWidget {
);
lines.push("".into());
}
lines.push(
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
);
lines.push(" Press Enter to continue".dim().into());
if let Some(err) = &self.error {
lines.push("".into());
lines.push(err.as_str().red().into());
@@ -395,7 +367,6 @@ impl AuthModeWidget {
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec![" ".into()];
if self.animations_enabled {
// 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_spans("Finish signing in via your browser"));
@@ -408,19 +379,16 @@ impl AuthModeWidget {
let auth_url = if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state
&& !state.auth_url.is_empty()
{
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
lines.push(
" If the link doesn't open automatically, open the following link to authenticate:"
.into(),
);
lines.push("".into());
lines.push(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 Esc and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
lines.push("".into());
Some(state.auth_url.clone())
} else {
None
@@ -431,8 +399,6 @@ impl AuthModeWidget {
.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);
}
@@ -600,7 +566,6 @@ impl AuthModeWidget {
}
_ => {}
}
// handled; let guard drop before potential save
} else {
return false;
}
@@ -673,34 +638,14 @@ impl AuthModeWidget {
self.disallow_api_login();
return;
}
match login_with_api_key(
&self.codex_home,
&api_key,
self.cli_auth_credentials_store_mode,
) {
Ok(()) => {
self.error = None;
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
self.auth_manager.reload();
*self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
}
Err(err) => {
self.error = Some(format!("Failed to save API key: {err}"));
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(existing) = &mut *guard {
if existing.value.is_empty() {
existing.value.push_str(&api_key);
}
existing.prepopulated_from_env = false;
} else {
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
}
self.error = None;
if self
.auth_command_tx
.send(AuthCommand::StartApiKey { api_key })
.is_err()
{
self.error = Some("Failed to start API key login".to_string());
}
self.request_frame.schedule_frame();
}
@@ -714,75 +659,102 @@ impl AuthModeWidget {
}
}
/// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt.
fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login
// just proceed to the success message flow.
if self.handle_existing_chatgpt_login() {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
let request_frame = self.request_frame.clone();
let auth_manager = self.auth_manager.clone();
tokio::spawn(async move {
let auth_url = child.auth_url.clone();
{
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(child.cancel_handle()),
});
}
request_frame.schedule_frame();
let r = child.block_until_done().await;
match r {
Ok(()) => {
// Force the auth manager to reload the new auth information.
auth_manager.reload();
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}
_ => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
// self.error = Some(e.to_string());
request_frame.schedule_frame();
}
}
});
}
Err(e) => {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(e.to_string());
self.request_frame.schedule_frame();
}
*self.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url: String::new(),
login_id: None,
});
if self
.auth_command_tx
.send(AuthCommand::StartChatgpt)
.is_err()
{
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some("Failed to start ChatGPT login".to_string());
}
self.request_frame.schedule_frame();
}
fn start_device_code_login(&mut self) {
if self.handle_existing_chatgpt_login() {
return;
}
pub(crate) fn apply_account(&mut self, account: Option<&Account>) {
self.login_status = login_status_from_account(account);
}
pub(crate) fn apply_chatgpt_login_started(&mut self, login_id: String, auth_url: String) {
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
*self.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
login_id: Some(login_id),
});
self.request_frame.schedule_frame();
}
pub(crate) fn apply_login_completed(&mut self, payload: AccountLoginCompletedNotification) {
match payload.login_id {
Some(login_id) => {
let mut guard = self.sign_in_state.write().unwrap();
let is_active_login = matches!(
&*guard,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: Some(active_login_id),
..
}) if *active_login_id == login_id
);
if !is_active_login {
return;
}
if payload.success {
self.login_status = LoginStatus::AuthMode(AuthMode::Chatgpt);
*guard = SignInState::ChatGptSuccessMessage;
self.error = None;
} else {
*guard = SignInState::PickMode;
self.error = Some(
payload
.error
.unwrap_or_else(|| "ChatGPT sign-in failed".to_string()),
);
}
}
None => {
let mut guard = self.sign_in_state.write().unwrap();
if !matches!(&*guard, SignInState::ApiKeyEntry(_)) {
return;
}
if payload.success {
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
*guard = SignInState::ApiKeyConfigured;
self.error = None;
} else {
self.error = Some(
payload
.error
.map(|err| format!("Failed to save API key: {err}"))
.unwrap_or_else(|| "Failed to save API key".to_string()),
);
}
}
}
self.request_frame.schedule_frame();
}
pub(crate) fn show_login_request_error(&mut self, message: String) {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(message);
self.request_frame.schedule_frame();
}
pub(crate) fn show_api_key_login_error(&mut self, message: String) {
self.error = Some(message);
self.request_frame.schedule_frame();
}
}
@@ -793,7 +765,6 @@ impl StepStateProvider for AuthModeWidget {
SignInState::PickMode
| SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
}
@@ -810,9 +781,6 @@ impl WidgetRef for AuthModeWidget {
SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf);
}
SignInState::ChatGptDeviceCode(state) => {
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
}
SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf);
}
@@ -832,37 +800,29 @@ impl WidgetRef for AuthModeWidget {
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use codex_core::auth::AuthCredentialsStoreMode;
fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf();
let widget = AuthModeWidget {
fn auth_widget(
forced_login_method: Option<ForcedLoginMethod>,
highlighted_mode: SignInOption,
) -> AuthModeWidget {
let (auth_command_tx, _auth_command_rx) = tokio::sync::mpsc::unbounded_channel();
AuthModeWidget {
auth_command_tx,
request_frame: FrameRequester::test_dummy(),
highlighted_mode: SignInOption::ChatGpt,
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
login_status: LoginStatus::NotAuthenticated,
auth_manager: AuthManager::shared(
codex_home_path,
false,
AuthCredentialsStoreMode::File,
),
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
forced_login_method,
animations_enabled: true,
};
(widget, codex_home)
}
}
#[test]
fn api_key_flow_disabled_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
let mut widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt);
widget.start_api_key_entry();
@@ -875,7 +835,7 @@ mod tests {
#[test]
fn saving_api_key_is_blocked_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
let mut widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt);
widget.save_api_key("sk-test".to_string());
@@ -887,8 +847,22 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
#[test]
fn device_code_login_shows_todo_error() {
let mut widget = auth_widget(None, SignInOption::DeviceCode);
widget.handle_sign_in_option(SignInOption::DeviceCode);
assert_eq!(
widget.error.as_deref(),
Some(DEVICE_CODE_UNAVAILABLE_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
let open = format!("\x1B]8;;{url}\x07");
let close = "\x1B]8;;\x07";
@@ -908,47 +882,52 @@ mod tests {
#[test]
fn continue_in_browser_renders_osc8_hyperlink() {
let (widget, _tmp) = widget_forced_chatgpt();
let widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt);
let url = "https://auth.example.com/login?state=abc123";
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url: url.to_string(),
shutdown_flag: None,
login_id: Some("login-123".to_string()),
});
// Render into a narrow buffer so the URL wraps across multiple rows.
let area = Rect::new(0, 0, 30, 20);
let mut buf = Buffer::empty(area);
widget.render_continue_in_browser(area, &mut buf);
// Every character of the URL should be present as an OSC 8 cell.
let found = collect_osc8_chars(&buf, area, url);
assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL");
}
#[test]
fn device_code_login_unavailable_snapshot() {
let mut widget = auth_widget(None, SignInOption::DeviceCode);
widget.handle_sign_in_option(SignInOption::DeviceCode);
let area = Rect::new(0, 0, 70, 18);
let mut buf = Buffer::empty(area);
widget.render_ref(area, &mut buf);
assert_snapshot!("device_code_login_unavailable", format!("{buf:?}"));
}
#[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");
}
@@ -957,24 +936,20 @@ mod tests {
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"

View File

@@ -1,387 +0,0 @@
use codex_core::AuthManager;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use codex_login::run_login_server;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::AuthModeWidget;
use super::ContinueInBrowserState;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) {
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let auth_manager = widget.auth_manager.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
let device_code = match request_device_code(&opts).await {
Ok(device_code) => device_code,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match run_login_server(opts) {
Ok(child) => {
let auth_url = child.auth_url.clone();
{
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(child.cancel_handle()),
});
}
request_frame.schedule_frame();
let r = child.block_until_done().await;
match r {
Ok(()) => {
auth_manager.reload();
*sign_in_state.write().unwrap() =
SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}
_ => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
request_frame.schedule_frame();
}
}
}
Err(_) => {
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
);
}
}
} else {
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
);
}
return;
}
};
if !set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: Some(device_code.clone()),
cancel: Some(cancel.clone()),
}),
) {
return;
}
tokio::select! {
_ = cancel.notified() => {}
r = complete_device_code_login(opts, device_code) => {
match r {
Ok(()) => {
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
);
}
Err(_) => {
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
);
}
}
}
}
});
}
pub(super) fn render_device_code_login(
widget: &AuthModeWidget,
area: Rect,
buf: &mut Buffer,
state: &ContinueWithDeviceCodeState,
) {
let banner = if state.device_code.is_some() {
"Finish signing in via your browser"
} else {
"Preparing device code login"
};
let mut spans = vec![" ".into()];
if widget.animations_enabled {
// Schedule a follow-up frame to keep the shimmer animation going.
widget
.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans(banner));
} else {
spans.push(banner.into());
}
let mut lines = vec![spans.into(), "".into()];
// Capture the verification URL for OSC 8 hyperlink marking after render.
let verification_url = if let Some(device_code) = &state.device_code {
lines.push(" 1. Open this link in your browser and sign in".into());
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
device_code.verification_url.as_str().cyan().underlined(),
]));
lines.push("".into());
lines.push(
" 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(),
);
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
device_code.user_code.as_str().cyan().bold(),
]));
lines.push("".into());
lines.push(
" Device codes are a common phishing target. Never share this code."
.dim()
.into(),
);
lines.push("".into());
Some(device_code.verification_url.clone())
} else {
lines.push(" Requesting a one-time code...".dim().into());
lines.push("".into());
None
};
lines.push(" Press Esc to cancel".dim().into());
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) = &verification_url {
mark_url_hyperlink(buf, area, url);
}
}
fn device_code_attempt_matches(state: &SignInState, cancel: &Arc<Notify>) -> bool {
matches!(
state,
SignInState::ChatGptDeviceCode(state)
if state
.cancel
.as_ref()
.is_some_and(|existing| Arc::ptr_eq(existing, cancel))
)
}
fn begin_device_code_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
) -> Arc<Notify> {
let cancel = Arc::new(Notify::new());
*sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
request_frame.schedule_frame();
cancel
}
fn set_device_code_state_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
cancel: &Arc<Notify>,
next_state: SignInState,
) -> bool {
let mut guard = sign_in_state.write().unwrap();
if !device_code_attempt_matches(&guard, cancel) {
return false;
}
*guard = next_state;
drop(guard);
request_frame.schedule_frame();
true
}
fn set_device_code_success_message_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
auth_manager: &AuthManager,
cancel: &Arc<Notify>,
) -> bool {
let mut guard = sign_in_state.write().unwrap();
if !device_code_attempt_matches(&guard, cancel) {
return false;
}
auth_manager.reload();
*guard = SignInState::ChatGptSuccessMessage;
drop(guard);
request_frame.schedule_frame();
true
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::auth::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel),
},
)))
}
#[test]
fn device_code_attempt_matches_only_for_matching_cancel() {
let cancel = Arc::new(Notify::new());
let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
assert_eq!(device_code_attempt_matches(&state, &cancel), true);
assert_eq!(
device_code_attempt_matches(&state, &Arc::new(Notify::new())),
false
);
assert_eq!(
device_code_attempt_matches(&SignInState::PickMode, &cancel),
false
);
}
#[test]
fn begin_device_code_attempt_sets_state() {
let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode));
let request_frame = FrameRequester::test_dummy();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
let guard = sign_in_state.read().unwrap();
let state: &SignInState = &guard;
assert_eq!(device_code_attempt_matches(state, &cancel), true);
assert!(matches!(
state,
SignInState::ChatGptDeviceCode(state) if state.device_code.is_none()
));
}
#[test]
fn set_device_code_state_for_active_attempt_updates_only_when_active() {
let request_frame = FrameRequester::test_dummy();
let cancel = Arc::new(Notify::new());
let sign_in_state = device_code_sign_in_state(cancel.clone());
assert_eq!(
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
),
true
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::PickMode
));
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
assert_eq!(
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
),
false
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn set_device_code_success_message_for_active_attempt_updates_only_when_active() {
let request_frame = FrameRequester::test_dummy();
let cancel = Arc::new(Notify::new());
let sign_in_state = device_code_sign_in_state(cancel.clone());
let temp_dir = TempDir::new().unwrap();
let auth_manager = AuthManager::shared(
temp_dir.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
assert_eq!(
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
),
true
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
assert_eq!(
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
),
false
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptDeviceCode(_)
));
}
}

View File

@@ -1,3 +1,4 @@
pub(crate) mod account_login;
mod auth;
pub mod onboarding_screen;
mod trust_directory;

View File

@@ -1,4 +1,7 @@
use codex_core::AuthManager;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessServerEvent;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::ServerNotification;
use codex_core::config::Config;
#[cfg(target_os = "windows")]
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
@@ -17,6 +20,8 @@ use ratatui::widgets::WidgetRef;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::onboarding::account_login::AuthCommand;
use crate::onboarding::account_login::OnboardingAccountApi;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
@@ -27,8 +32,10 @@ use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use color_eyre::eyre::eyre;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::mpsc;
#[allow(clippy::large_enum_variant)]
enum Step {
@@ -64,7 +71,6 @@ pub(crate) struct OnboardingScreenArgs {
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
@@ -74,19 +80,20 @@ pub(crate) struct OnboardingResult {
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
pub(crate) fn new(
tui: &mut Tui,
args: OnboardingScreenArgs,
auth_command_tx: Option<mpsc::UnboundedSender<AuthCommand>>,
) -> Self {
let OnboardingScreenArgs {
show_trust_screen,
show_login_screen,
login_status,
auth_manager,
config,
} = args;
let cwd = config.cwd.clone();
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let forced_login_method = config.forced_login_method;
let codex_home = config.codex_home.clone();
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
let forced_login_method = config.forced_login_method;
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
@@ -98,16 +105,16 @@ impl OnboardingScreen {
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
let Some(auth_command_tx) = auth_command_tx else {
unreachable!("auth command sender should exist when login screen is shown");
};
steps.push(Step::Auth(AuthModeWidget {
auth_command_tx,
request_frame: tui.frame_requester(),
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode,
login_status,
auth_manager,
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
}))
@@ -210,6 +217,13 @@ impl OnboardingScreen {
false
})
}
fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> {
self.steps.iter_mut().find_map(|step| match step {
Step::Auth(widget) => Some(widget),
Step::Welcome(_) | Step::TrustDirectory(_) => None,
})
}
}
impl KeyboardHandler for OnboardingScreen {
@@ -395,10 +409,14 @@ impl WidgetRef for Step {
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
tui: &mut Tui,
app_server: Option<&mut InProcessAppServerClient>,
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
let mut onboarding_screen = OnboardingScreen::new(tui, args);
let show_login_screen = args.show_login_screen;
let (auth_command_tx, mut auth_command_rx) = mpsc::unbounded_channel();
let mut onboarding_screen =
OnboardingScreen::new(tui, args, show_login_screen.then_some(auth_command_tx));
// One-time guard to fully clear the screen after ChatGPT login success message is shown
let mut did_full_clear_after_success = false;
@@ -408,56 +426,178 @@ pub(crate) async fn run_onboarding_app(
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
let mut account_api = OnboardingAccountApi::default();
while !onboarding_screen.is_done() {
if let Some(event) = tui_events.next().await {
match event {
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
if show_login_screen {
let app_server = app_server.ok_or_else(|| eyre!("missing app server for onboarding"))?;
while !onboarding_screen.is_done() {
tokio::select! {
Some(event) = tui_events.next() => {
handle_tui_event(
event,
tui,
&mut onboarding_screen,
&mut did_full_clear_after_success,
);
}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
Some(command) = auth_command_rx.recv() => {
handle_auth_command(
command,
app_server,
&mut account_api,
&mut onboarding_screen,
).await;
}
TuiEvent::Draw => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {
w.sign_in_state.read().is_ok_and(|g| {
matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage)
})
} else {
false
}
})
{
// Reset any lingering SGR (underline/color) before clearing
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::Reset
),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::NoUnderline
),
ratatui::crossterm::style::SetForegroundColor(
ratatui::crossterm::style::Color::Reset
),
ratatui::crossterm::style::SetBackgroundColor(
ratatui::crossterm::style::Color::Reset
)
);
let _ = tui.terminal.clear();
did_full_clear_after_success = true;
app_server_event = app_server.next_event() => {
if let Some(event) = app_server_event {
handle_app_server_event(
event,
app_server,
&mut account_api,
&mut onboarding_screen,
).await;
} else {
break;
}
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&onboarding_screen, frame.area());
});
}
}
}
} else {
while !onboarding_screen.is_done() {
if let Some(event) = tui_events.next().await {
handle_tui_event(
event,
tui,
&mut onboarding_screen,
&mut did_full_clear_after_success,
);
}
}
}
Ok(OnboardingResult {
directory_trust_decision: onboarding_screen.directory_trust_decision(),
should_exit: onboarding_screen.should_exit(),
})
}
fn handle_tui_event(
event: TuiEvent,
tui: &mut Tui,
onboarding_screen: &mut OnboardingScreen,
did_full_clear_after_success: &mut bool,
) {
match event {
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Draw => {
if !*did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {
w.sign_in_state.read().is_ok_and(|g| {
matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage)
})
} else {
false
}
})
{
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::Reset
),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::NoUnderline
),
ratatui::crossterm::style::SetForegroundColor(
ratatui::crossterm::style::Color::Reset
),
ratatui::crossterm::style::SetBackgroundColor(
ratatui::crossterm::style::Color::Reset
)
);
let _ = tui.terminal.clear();
*did_full_clear_after_success = true;
}
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&*onboarding_screen, frame.area());
});
}
}
}
async fn handle_auth_command(
command: AuthCommand,
app_server: &InProcessAppServerClient,
account_api: &mut OnboardingAccountApi,
onboarding_screen: &mut OnboardingScreen,
) {
match command {
AuthCommand::StartApiKey { api_key } => {
if let Err(err) = account_api.start_api_key_login(app_server, api_key).await
&& let Some(auth_widget) = onboarding_screen.auth_widget_mut()
{
auth_widget.show_api_key_login_error(format!("Failed to save API key: {err}"));
}
}
AuthCommand::StartChatgpt => {
let result = account_api.start_chatgpt_login(app_server).await;
if let Some(auth_widget) = onboarding_screen.auth_widget_mut() {
match result {
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
auth_widget.apply_chatgpt_login_started(login_id, auth_url);
}
Ok(response) => {
auth_widget.show_login_request_error(format!(
"Unexpected account/login/start response: {response:?}"
));
}
Err(err) => {
auth_widget.show_login_request_error(err);
}
}
}
}
AuthCommand::CancelChatgpt { login_id } => {
if let Err(err) = account_api.cancel_chatgpt_login(app_server, login_id).await {
tracing::warn!("failed to cancel onboarding login: {err}");
}
}
}
}
async fn handle_app_server_event(
event: InProcessServerEvent,
app_server: &InProcessAppServerClient,
account_api: &mut OnboardingAccountApi,
onboarding_screen: &mut OnboardingScreen,
) {
match event {
InProcessServerEvent::ServerNotification(ServerNotification::AccountUpdated(_)) => {
match account_api.read_account(app_server).await {
Ok(response) => {
if let Some(auth_widget) = onboarding_screen.auth_widget_mut() {
auth_widget.apply_account(response.account.as_ref());
}
}
Err(err) => tracing::warn!("failed to refresh onboarding account state: {err}"),
}
}
InProcessServerEvent::ServerNotification(ServerNotification::AccountLoginCompleted(
payload,
)) => {
if let Some(auth_widget) = onboarding_screen.auth_widget_mut() {
auth_widget.apply_login_completed(payload);
}
}
InProcessServerEvent::LegacyNotification(_)
| InProcessServerEvent::ServerNotification(_)
| InProcessServerEvent::ServerRequest(_)
| InProcessServerEvent::Lagged { .. } => {}
}
}

View File

@@ -0,0 +1,45 @@
---
source: tui/src/onboarding/auth.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 70, height: 18 },
content: [
" Sign in with ChatGPT to use Codex as part of your paid plan ",
" or connect an API key for usage-based billing ",
" ",
" 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. Provide your own API key ",
" Pay for what you use ",
" ",
" Press Enter to continue ",
" ",
"Device code sign-in is not yet available through the app-server ",
"onboarding flow. Use browser sign-in instead. ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 66, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM,
x: 5, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 25, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 25, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 14, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 63, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 15, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}