Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952)

## Summary
- add device-code ChatGPT sign-in to `tui_app_server` onboarding and
reuse the existing `chatgptAuthTokens` login path
- fall back to browser login when device-code auth is unavailable on the
server
- treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state
during onboarding
- add a local ChatGPT auth loader for handing local tokens to the app
server and serving refresh requests
- handle `account/chatgptAuthTokens/refresh` instead of marking it
unsupported, including workspace/account mismatch checks
- add focused coverage for onboarding success, existing auth handling,
local auth loading, and refresh request behavior

## Testing
- `cargo test -p codex-tui-app-server`
- `just fix -p codex-tui-app-server`
This commit is contained in:
Eric Traut
2026-03-17 14:12:12 -06:00
committed by GitHub
Unverified
parent 95bdea93d2
commit 49e7dda2df
6 changed files with 713 additions and 15 deletions
+17 -5
View File
@@ -104,8 +104,6 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const APP_SERVER_TUI_UNSUPPORTED_MESSAGE: &str = "Not available in app-server TUI yet.";
fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
@@ -741,6 +739,7 @@ impl AuthModeWidget {
if matches!(
self.login_status,
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
@@ -799,9 +798,8 @@ impl AuthModeWidget {
return;
}
self.set_error(Some(APP_SERVER_TUI_UNSUPPORTED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
@@ -978,6 +976,20 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
@@ -1,6 +1,12 @@
#![allow(dead_code)]
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -13,17 +19,106 @@ use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use crate::local_chatgpt_auth::LocalChatgptAuth;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
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;
use super::onboarding_request_id;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
let _ = opts;
let _ = widget;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) {
let mut opts = ServerOptions::new(
widget.codex_home.clone(),
CLIENT_ID.to_string(),
widget.forced_chatgpt_workspace_id.clone(),
widget.cli_auth_credentials_store_mode,
);
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let error = widget.error.clone();
let request_handle = widget.app_server_request_handle.clone();
let codex_home = widget.codex_home.clone();
let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode;
let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.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 {
fallback_to_browser_login(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
)
.await;
} else {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
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() => {}
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
let local_auth = load_local_chatgpt_auth(
&codex_home,
cli_auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
);
handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
local_auth,
).await;
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
}
});
}
pub(super) fn render_device_code_login(
@@ -151,6 +246,159 @@ fn set_device_code_success_message_for_active_attempt(
true
}
fn set_device_code_error_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
message: String,
) -> bool {
if !set_device_code_state_for_active_attempt(
sign_in_state,
request_frame,
cancel,
SignInState::PickMode,
) {
return false;
}
*error.write().unwrap() = Some(message);
request_frame.schedule_frame();
true
}
async fn fallback_to_browser_login(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
) {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
*error.write().unwrap() = None;
let _updated = set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
}),
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
local_auth: Result<LocalChatgptAuth, String>,
) {
let local_auth = match local_auth {
Ok(local_auth) => local_auth,
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err,
);
return;
}
};
let result = request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ChatgptAuthTokens {
access_token: local_auth.access_token,
chatgpt_account_id: local_auth.chatgpt_account_id,
chatgpt_plan_type: local_auth.chatgpt_plan_type,
},
})
.await;
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
result.map_err(|err| err.to_string()),
);
}
fn apply_chatgpt_auth_tokens_login_response_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
result: Result<LoginAccountResponse, String>,
) {
match result {
Ok(LoginAccountResponse::ChatgptAuthTokens {}) => {
*error.write().unwrap() = None;
let _updated = set_device_code_success_message_for_active_attempt(
sign_in_state,
request_frame,
cancel,
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
err,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -269,4 +517,30 @@ mod tests {
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() {
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
let request_frame = FrameRequester::test_dummy();
let error = Arc::new(RwLock::new(None));
let cancel = match &*sign_in_state.read().unwrap() {
SignInState::ChatGptDeviceCode(state) => {
state.cancel.as_ref().expect("cancel handle").clone()
}
_ => panic!("expected device-code state"),
};
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
Ok(LoginAccountResponse::ChatgptAuthTokens {}),
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
}