From 49e7dda2dfd6e67dd5f9dd8bfa22b7c2b1df17ef Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 17 Mar 2026 14:12:12 -0600 Subject: [PATCH] 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` --- .../src/app/app_server_adapter.rs | 206 +++++++++++++ .../src/app/app_server_requests.rs | 24 +- codex-rs/tui_app_server/src/lib.rs | 1 + .../tui_app_server/src/local_chatgpt_auth.rs | 195 ++++++++++++ .../tui_app_server/src/onboarding/auth.rs | 22 +- .../onboarding/auth/headless_chatgpt_login.rs | 280 +++++++++++++++++- 6 files changed, 713 insertions(+), 15 deletions(-) create mode 100644 codex-rs/tui_app_server/src/local_chatgpt_auth.rs diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 0fff49fd2..6c54bf3ce 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -16,9 +16,12 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::app_server_rate_limit_snapshot_to_core; use crate::app_server_session::status_account_display_from_auth_mode; +use crate::local_chatgpt_auth::load_local_chatgpt_auth; use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; @@ -90,6 +93,7 @@ impl App { matches!( notification.auth_mode, Some(codex_app_server_protocol::AuthMode::Chatgpt) + | Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens) ), ); } @@ -150,6 +154,15 @@ impl App { } } AppServerEvent::ServerRequest(request) => { + if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { + self.handle_chatgpt_auth_tokens_refresh_request( + app_server_client, + request_id, + params, + ) + .await; + return; + } if let Some(unsupported) = self .pending_app_server_requests .note_server_request(&request) @@ -181,6 +194,70 @@ impl App { } } + async fn handle_chatgpt_auth_tokens_refresh_request( + &mut self, + app_server_client: &AppServerSession, + request_id: codex_app_server_protocol::RequestId, + params: ChatgptAuthTokensRefreshParams, + ) { + let config = self.config.clone(); + let result = tokio::task::spawn_blocking(move || { + resolve_chatgpt_auth_tokens_refresh_response( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.forced_chatgpt_workspace_id.as_deref(), + ¶ms, + ) + }) + .await; + + match result { + Ok(Ok(response)) => { + let response = serde_json::to_value(response).map_err(|err| { + format!("failed to serialize chatgpt auth refresh response: {err}") + }); + match response { + Ok(response) => { + if let Err(err) = app_server_client + .resolve_server_request(request_id, response) + .await + { + tracing::warn!("failed to resolve chatgpt auth refresh request: {err}"); + } + } + Err(err) => { + self.chat_widget.add_error_message(err.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, err) + .await + { + tracing::warn!("{reject_err}"); + } + } + } + } + Ok(Err(err)) => { + self.chat_widget.add_error_message(err.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, err) + .await + { + tracing::warn!("{reject_err}"); + } + } + Err(err) => { + let message = format!("chatgpt auth refresh task failed: {err}"); + self.chat_widget.add_error_message(message.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server_client, request_id, message) + .await + { + tracing::warn!("{reject_err}"); + } + } + } + } + async fn reject_app_server_request( &self, app_server_client: &AppServerSession, @@ -201,6 +278,28 @@ impl App { } } +fn resolve_chatgpt_auth_tokens_refresh_response( + codex_home: &std::path::Path, + auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&str>, + params: &ChatgptAuthTokensRefreshParams, +) -> Result { + let auth = load_local_chatgpt_auth( + codex_home, + auth_credentials_store_mode, + forced_chatgpt_workspace_id, + )?; + if let Some(previous_account_id) = params.previous_account_id.as_deref() + && previous_account_id != auth.chatgpt_account_id + { + return Err(format!( + "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", + auth.chatgpt_account_id + )); + } + Ok(auth.to_refresh_response()) +} + /// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s /// suitable for replaying into the TUI event store. /// @@ -624,6 +723,113 @@ fn thread_item_to_core(item: &ThreadItem) -> Option { } } +#[cfg(test)] +mod refresh_tests { + use super::*; + + use base64::Engine; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::TempDir; + + fn fake_jwt(account_id: &str, plan_type: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + fn write_chatgpt_auth(codex_home: &std::path::Path) { + let id_token = fake_jwt("workspace-1", "business"); + let access_token = fake_jwt("workspace-1", "business"); + save_auth( + codex_home, + &AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: Some(Utc::now()), + }, + AuthCredentialsStoreMode::File, + ) + .expect("chatgpt auth should save"); + } + + #[test] + fn refresh_request_uses_local_chatgpt_auth() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let response = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }, + ) + .expect("refresh response should resolve"); + + assert_eq!(response.chatgpt_account_id, "workspace-1"); + assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!response.access_token.is_empty()); + } + + #[test] + fn refresh_request_rejects_account_mismatch() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let err = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-2".to_string()), + }, + ) + .expect_err("mismatched account should fail"); + + assert_eq!( + err, + "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" + ); + } +} + fn app_server_web_search_action_to_core( action: codex_app_server_protocol::WebSearchAction, ) -> Option { diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 1975f3606..3e65f8dd6 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -99,13 +99,7 @@ impl PendingAppServerRequests { .to_string(), }) } - ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { - Some(UnsupportedAppServerRequest { - request_id: request_id.clone(), - message: "ChatGPT auth token refresh is not available in app-server TUI yet." - .to_string(), - }) - } + ServerRequest::ChatgptAuthTokensRefresh { .. } => None, ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -608,6 +602,22 @@ mod tests { ); } + #[test] + fn does_not_mark_chatgpt_auth_refresh_as_unsupported() { + let mut pending = PendingAppServerRequests::default(); + + assert_eq!( + pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh { + request_id: AppServerRequestId::Integer(100), + params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }, + }), + None + ); + } + #[test] fn rejects_invalid_patch_decisions_for_file_change_requests() { let mut pending = PendingAppServerRequests::default(); diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 0546d8848..62a428918 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -100,6 +100,7 @@ pub mod insert_history; mod key_hint; mod line_truncation; pub mod live_wrap; +mod local_chatgpt_auth; mod markdown; mod markdown_render; mod markdown_stream; diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs new file mode 100644 index 000000000..89c7769f0 --- /dev/null +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -0,0 +1,195 @@ +use std::path::Path; + +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::load_auth_dot_json; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalChatgptAuth { + pub(crate) access_token: String, + pub(crate) chatgpt_account_id: String, + pub(crate) chatgpt_plan_type: Option, +} + +impl LocalChatgptAuth { + pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse { + ChatgptAuthTokensRefreshResponse { + access_token: self.access_token.clone(), + chatgpt_account_id: self.chatgpt_account_id.clone(), + chatgpt_plan_type: self.chatgpt_plan_type.clone(), + } + } +} + +pub(crate) fn load_local_chatgpt_auth( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&str>, +) -> Result { + let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) + .map_err(|err| format!("failed to load local auth: {err}"))? + .ok_or_else(|| "no local auth available".to_string())?; + if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() { + return Err("local auth is not a ChatGPT login".to_string()); + } + + let tokens = auth + .tokens + .ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?; + let access_token = tokens.access_token; + let chatgpt_account_id = tokens + .account_id + .or(tokens.id_token.chatgpt_account_id.clone()) + .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; + if let Some(expected_workspace) = forced_chatgpt_workspace_id + && chatgpt_account_id != expected_workspace + { + return Err(format!( + "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + )); + } + + let chatgpt_plan_type = tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()); + + Ok(LocalChatgptAuth { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use base64::Engine; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::login_with_chatgpt_auth_tokens; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::TempDir; + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + fn write_chatgpt_auth(codex_home: &Path) { + let id_token = fake_jwt("user@example.com", "workspace-1", "business"); + let access_token = fake_jwt("user@example.com", "workspace-1", "business"); + let auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) + .expect("chatgpt auth should save"); + } + + #[test] + fn loads_local_chatgpt_auth_from_managed_auth() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let auth = load_local_chatgpt_auth( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + ) + .expect("chatgpt auth should load"); + + assert_eq!(auth.chatgpt_account_id, "workspace-1"); + assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!auth.access_token.is_empty()); + } + + #[test] + fn rejects_missing_local_auth() { + let codex_home = TempDir::new().expect("tempdir"); + + let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None) + .expect_err("missing auth should fail"); + + assert_eq!(err, "no local auth available"); + } + + #[test] + fn rejects_api_key_auth() { + let codex_home = TempDir::new().expect("tempdir"); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + }, + AuthCredentialsStoreMode::File, + ) + .expect("api key auth should save"); + + let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None) + .expect_err("api key auth should fail"); + + assert_eq!(err, "local auth is not a ChatGPT login"); + } + + #[test] + fn prefers_managed_auth_over_external_ephemeral_tokens() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + login_with_chatgpt_auth_tokens( + codex_home.path(), + &fake_jwt("user@example.com", "workspace-2", "enterprise"), + "workspace-2", + Some("enterprise"), + ) + .expect("external auth should save"); + + let auth = load_local_chatgpt_auth( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + ) + .expect("managed auth should win"); + + assert_eq!(auth.chatgpt_account_id, "workspace-1"); + assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business")); + } +} diff --git a/codex-rs/tui_app_server/src/onboarding/auth.rs b/codex-rs/tui_app_server/src/onboarding/auth.rs index d089f741a..612fdbe2a 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth.rs @@ -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 { diff --git a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs index c967f7621..33afe740b 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs @@ -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>, + request_frame: &FrameRequester, + error: &Arc>>, + cancel: &Arc, + 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>, + request_frame: FrameRequester, + error: Arc>>, + cancel: Arc, +) { + 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::(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>, + request_frame: FrameRequester, + error: Arc>>, + cancel: Arc, + local_auth: Result, +) { + 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::(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>, + request_frame: &FrameRequester, + error: &Arc>>, + cancel: &Arc, + result: Result, +) { + 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 + )); + } }