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:
parent
95bdea93d2
commit
49e7dda2df
6 changed files with 713 additions and 15 deletions
|
|
@ -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<codex_app_server_protocol::ChatgptAuthTokensRefreshResponse, String> {
|
||||
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<TurnItem> {
|
|||
}
|
||||
}
|
||||
|
||||
#[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<codex_protocol::models::WebSearchAction> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
195
codex-rs/tui_app_server/src/local_chatgpt_auth.rs
Normal file
195
codex-rs/tui_app_server/src/local_chatgpt_auth.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<LocalChatgptAuth, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue