mirror of
https://github.com/openai/codex.git
synced 2026-09-14 11:57:03 +00:00
Part 3
This commit is contained in:
@@ -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,
|
||||
|
||||
126
codex-rs/tui/src/onboarding/account_login.rs
Normal file
126
codex-rs/tui/src/onboarding/account_login.rs
Normal 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}"))
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod account_login;
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user