mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
95bdea93d2
commit
49e7dda2df
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user