diff --git a/app/src/ai/chatgpt_auth.rs b/app/src/ai/chatgpt_auth.rs index b74ef59c..4d989f1a 100644 --- a/app/src/ai/chatgpt_auth.rs +++ b/app/src/ai/chatgpt_auth.rs @@ -1,24 +1,26 @@ //! ChatGPT subscription browser OAuth state used by the AI settings page. -//! -//! Instead of using Rig's device-code flow (which requires users to copy a code), -//! this module implements a standard OAuth 2.0 Authorization Code + PKCE flow: -//! 1. Open the browser to OpenAI's authorize endpoint -//! 2. User approves in browser -//! 3. Browser redirects back to `galaxy://chatgpt/oauth2callback?code=...&state=...` -//! 4. We exchange the code for tokens and write them to Rig's auth file + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::time::Duration; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; -use galaxy_core::channel::ChannelState; +use futures::channel::oneshot; use galaxyui::{Entity, ModelContext, SingletonEntity}; -use rand::Rng; +use instant::Instant; +use rand::RngCore; use sha2::{Digest, Sha256}; use url::Url; -const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/api/accounts/authorize"; -const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/oauth/token"; +const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; +const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CHATGPT_SCOPES: &str = "openid profile email offline_access"; +const CALLBACK_HOST: &str = "localhost"; +const CALLBACK_PORTS: [u16; 2] = [1455, 1457]; +const CALLBACK_PATH: &str = "/auth/callback"; +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); /// Current state of the local ChatGPT subscription connection. #[derive(Clone, Debug, PartialEq, Eq)] @@ -35,19 +37,20 @@ pub(crate) enum ChatGPTAuthModelEvent { StateChanged, } +struct OAuthCallback { + code: String, + state: String, +} + /// Coordinates browser-based OAuth authorization for ChatGPT subscriptions. pub(crate) struct ChatGPTAuthModel { state: ChatGPTAuthState, - /// PKCE code verifier stored between authorize and callback. - pending_code_verifier: Option, - /// CSRF state token stored between authorize and callback. - pending_state: Option, } impl ChatGPTAuthModel { pub(crate) fn new() -> Self { - let state = match load_or_import_auth_credentials() { - Ok(_) => ChatGPTAuthState::Connected, + let state = match has_or_import_auth_credentials() { + Ok(()) => ChatGPTAuthState::Connected, Err(error) => { log::debug!( "[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}" @@ -55,18 +58,14 @@ impl ChatGPTAuthModel { ChatGPTAuthState::NotConnected } }; - Self { - state, - pending_code_verifier: None, - pending_state: None, - } + Self { state } } pub(crate) fn state(&self) -> &ChatGPTAuthState { &self.state } - /// Attempts to connect using existing Codex credentials, falling back to browser OAuth. + /// Reuses persisted credentials when possible, otherwise starts browser OAuth with PKCE. pub(crate) fn connect(&mut self, ctx: &mut ModelContext) { if matches!( self.state, @@ -75,105 +74,75 @@ impl ChatGPTAuthModel { return; } - // Try to import credentials from ~/.codex/auth.json first. - if let Ok(()) = import_codex_credentials() { + if has_or_import_auth_credentials().is_ok() { self.state = ChatGPTAuthState::Connected; ctx.emit(ChatGPTAuthModelEvent::StateChanged); return; } - // No existing credentials — start the browser OAuth flow. - let code_verifier = generate_random_string(64); - let code_challenge = compute_code_challenge(&code_verifier); - let state = generate_random_string(32); - let redirect_uri = chatgpt_redirect_uri(); + let (redirect_uri, callback_rx) = match start_oauth_callback_server() { + Ok(server) => server, + Err(error) => { + self.fail(error, ctx); + return; + } + }; + let code_verifier = random_urlsafe_string(32); + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); + let oauth_state = random_hex_string(16); + let authorize_url = match build_authorize_url(&redirect_uri, &code_challenge, &oauth_state) + { + Ok(url) => url, + Err(error) => { + self.fail(error, ctx); + return; + } + }; - let authorize_url = format!( - "{CHATGPT_AUTHORIZE_URL}?\ - client_id={CHATGPT_CLIENT_ID}\ - &response_type=code\ - &redirect_uri={redirect_uri}\ - &code_challenge={code_challenge}\ - &code_challenge_method=S256\ - &state={state}\ - &scope={}", - urlencoding::encode(CHATGPT_SCOPES), - ); - - self.pending_code_verifier = Some(code_verifier); - self.pending_state = Some(state); self.state = ChatGPTAuthState::AwaitingBrowser; ctx.emit(ChatGPTAuthModelEvent::StateChanged); - - ctx.open_url(&authorize_url); - } - - /// Called when the OS routes back `galaxy://chatgpt/oauth2callback?code=...&state=...` - pub(crate) fn handle_oauth_callback(&mut self, url: &Url, ctx: &mut ModelContext) { - let Some(expected_state) = self.pending_state.take() else { - self.fail( - "Received OAuth callback but no authorization was in progress.", - ctx, - ); - return; - }; - let Some(code_verifier) = self.pending_code_verifier.take() else { - self.fail("Received OAuth callback but code verifier is missing.", ctx); - return; - }; - - // Extract query parameters - let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); - - // Check for error response from the authorization server - if let Some(error) = params.get("error") { - let description = params - .get("error_description") - .map(|d| d.to_string()) - .unwrap_or_else(|| error.to_string()); - self.fail(&format!("ChatGPT authorization denied: {description}"), ctx); - return; - } - - let Some(code) = params.get("code") else { - self.fail("OAuth callback missing authorization code.", ctx); - return; - }; - let code = code.to_string(); - - let Some(state) = params.get("state") else { - self.fail("OAuth callback missing state parameter.", ctx); - return; - }; - - if *state != expected_state { - self.fail("OAuth callback state mismatch (possible CSRF).", ctx); - return; - } - - self.state = ChatGPTAuthState::ExchangingToken; - ctx.emit(ChatGPTAuthModelEvent::StateChanged); - - let redirect_uri = chatgpt_redirect_uri(); + ctx.open_url(authorize_url.as_str()); let _ = ctx.spawn( - async move { exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await }, - |model, result, ctx| match result { - Ok(()) => { - model.state = ChatGPTAuthState::Connected; - ctx.emit(ChatGPTAuthModelEvent::StateChanged); - } - Err(error) => { - model.fail(&error, ctx); + async move { + callback_rx.await.map_err(|_| { + "ChatGPT OAuth callback server stopped unexpectedly.".to_string() + })? + }, + move |model, callback, ctx| { + let callback = match callback { + Ok(callback) => callback, + Err(error) => { + model.fail(error, ctx); + return; + } + }; + if callback.state != oauth_state { + model.fail("ChatGPT OAuth state mismatch.".to_string(), ctx); + return; } + + model.state = ChatGPTAuthState::ExchangingToken; + ctx.emit(ChatGPTAuthModelEvent::StateChanged); + let _ = ctx.spawn( + async move { + exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri) + .await + }, + |model, result, ctx| match result { + Ok(()) => { + model.state = ChatGPTAuthState::Connected; + ctx.emit(ChatGPTAuthModelEvent::StateChanged); + } + Err(error) => model.fail(error, ctx), + }, + ); }, ); } - fn fail(&mut self, message: &str, ctx: &mut ModelContext) { - self.state = ChatGPTAuthState::Failed(message.to_string()); - self.pending_code_verifier = None; - self.pending_state = None; + fn fail(&mut self, error: String, ctx: &mut ModelContext) { + self.state = ChatGPTAuthState::Failed(error); ctx.emit(ChatGPTAuthModelEvent::StateChanged); } } @@ -184,12 +153,202 @@ impl Entity for ChatGPTAuthModel { impl SingletonEntity for ChatGPTAuthModel {} -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +fn build_authorize_url( + redirect_uri: &str, + code_challenge: &str, + state: &str, +) -> Result { + let mut url = Url::parse(CHATGPT_AUTHORIZE_URL) + .map_err(|error| format!("Could not build the ChatGPT authorization URL: {error}"))?; + url.query_pairs_mut() + .append_pair("client_id", CHATGPT_CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", CHATGPT_SCOPES) + .append_pair("response_type", "code") + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("state", state) + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("originator", "galaxy"); + Ok(url) +} -fn chatgpt_redirect_uri() -> String { - format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme()) +fn random_urlsafe_string(byte_count: usize) -> String { + let mut bytes = vec![0; byte_count]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn random_hex_string(byte_count: usize) -> String { + let mut bytes = vec![0; byte_count]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn start_oauth_callback_server( +) -> Result<(String, oneshot::Receiver>), String> { + let mut bind_errors = Vec::new(); + let mut bound = None; + for port in CALLBACK_PORTS { + match TcpListener::bind((CALLBACK_HOST, port)) { + Ok(listener) => { + bound = Some((listener, port)); + break; + } + Err(error) => bind_errors.push(format!("{CALLBACK_HOST}:{port}: {error}")), + } + } + let (listener, port) = bound.ok_or_else(|| { + format!( + "Could not start the ChatGPT OAuth callback server on ports 1455 or 1457: {}", + bind_errors.join("; ") + ) + })?; + listener.set_nonblocking(true).map_err(|error| { + format!("Could not configure the ChatGPT OAuth callback server: {error}") + })?; + + let (callback_tx, callback_rx) = oneshot::channel(); + std::thread::Builder::new() + .name("chatgpt-oauth-callback".to_string()) + .spawn(move || { + let deadline = Instant::now() + CALLBACK_TIMEOUT; + loop { + match listener.accept() { + Ok((stream, _)) => { + let _ = callback_tx.send(handle_callback_connection(stream)); + return; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + let _ = callback_tx.send(Err( + "Timed out waiting for ChatGPT authorization.".to_string(), + )); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + let _ = callback_tx.send(Err(format!( + "ChatGPT OAuth callback server failed: {error}" + ))); + return; + } + } + } + }) + .map_err(|error| format!("Could not start the ChatGPT OAuth callback thread: {error}"))?; + + Ok(( + format!("http://{CALLBACK_HOST}:{port}{CALLBACK_PATH}"), + callback_rx, + )) +} + +fn handle_callback_connection(mut stream: TcpStream) -> Result { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .map_err(|error| format!("Could not configure the OAuth callback connection: {error}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .map_err(|error| format!("Could not configure the OAuth callback connection: {error}"))?; + + let read_stream = stream + .try_clone() + .map_err(|error| format!("Could not read the OAuth callback: {error}"))?; + let mut reader = BufReader::new(read_stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|error| format!("Could not read the OAuth callback request: {error}"))?; + loop { + let mut header = String::new(); + reader + .read_line(&mut header) + .map_err(|error| format!("Could not read the OAuth callback headers: {error}"))?; + if header == "\r\n" || header == "\n" || header.is_empty() { + break; + } + } + + let result = parse_callback_request_line(&request_line); + let (status, title, message) = if result.is_ok() { + ( + "200 OK", + "Authorization Successful", + "You can close this tab and return to Galaxy.", + ) + } else { + ( + "400 Bad Request", + "Authorization Failed", + "Something went wrong. Return to Galaxy and try again.", + ) + }; + let body = callback_response_page(title, message, result.is_err()); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + if let Err(error) = stream.write_all(response.as_bytes()) { + log::warn!("[chatgpt/auth] Could not write the OAuth callback response: {error}"); + } + result +} + +fn parse_callback_request_line(request_line: &str) -> Result { + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let target = parts.next().unwrap_or_default(); + if method != "GET" || target.is_empty() { + return Err("Invalid ChatGPT OAuth callback request.".to_string()); + } + + let url = Url::parse(&format!("http://{CALLBACK_HOST}{target}")) + .map_err(|error| format!("Malformed ChatGPT OAuth callback: {error}"))?; + if url.path() != CALLBACK_PATH { + return Err(format!( + "Unexpected ChatGPT OAuth callback path: {}", + url.path() + )); + } + + let mut code = None; + let mut state = None; + let mut oauth_error = None; + let mut error_description = None; + for (key, value) in url.query_pairs() { + match key.as_ref() { + "code" if !value.is_empty() => code = Some(value.into_owned()), + "state" if !value.is_empty() => state = Some(value.into_owned()), + "error" if !value.is_empty() => oauth_error = Some(value.into_owned()), + "error_description" if !value.is_empty() => { + error_description = Some(value.into_owned()) + } + "code" | "state" | "error" | "error_description" => {} + _ => {} + } + } + + if let Some(error) = oauth_error { + return Err(format!( + "ChatGPT authorization failed: {error} ({})", + error_description.as_deref().unwrap_or("no description") + )); + } + Ok(OAuthCallback { + code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?, + state: state.ok_or("ChatGPT OAuth callback did not include state.")?, + }) +} + +fn callback_response_page(title: &str, message: &str, is_error: bool) -> String { + let accent = if is_error { "#ff6b6b" } else { "#6ee7b7" }; + r#"__TITLE__ - Galaxy

__TITLE__

__MESSAGE__

Galaxy
"# + .replace("__TITLE__", title) + .replace("__MESSAGE__", message) + .replace("__ACCENT__", accent) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -208,27 +367,17 @@ pub(crate) fn import_codex_credentials() -> Result<(), String> { let tokens = doc.get("tokens").ok_or("No tokens object")?; let access_token = tokens .get("access_token") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or("No access_token")?; - - let expires_at = extract_expiration_timestamp(access_token); - - // If the token is expired, don't import stale credentials. - if let Some(exp) = expires_at { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if now >= exp - 60 { - return Err("Codex access token is expired".to_string()); - } - } - + .and_then(|value| value.as_str()) + .filter(|token| !token.is_empty()); let refresh_token = tokens .get("refresh_token") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) + .filter(|token| !token.is_empty()) .map(ToOwned::to_owned); + if access_token.is_none() && refresh_token.is_none() { + return Err("Codex auth file does not contain an access or refresh token".to_string()); + } + let expires_at = access_token.and_then(extract_expiration_timestamp); let id_token = tokens .get("id_token") .and_then(|v| v.as_str()) @@ -238,10 +387,10 @@ pub(crate) fn import_codex_credentials() -> Result<(), String> { .and_then(|v| v.as_str()) .map(ToOwned::to_owned) .or_else(|| extract_account_id(id_token.as_deref())) - .or_else(|| extract_account_id(Some(access_token))); + .or_else(|| extract_account_id(access_token)); let record = AuthRecord { - access_token: Some(access_token.to_owned()), + access_token: access_token.map(ToOwned::to_owned), refresh_token, id_token, expires_at, @@ -251,15 +400,54 @@ pub(crate) fn import_codex_credentials() -> Result<(), String> { write_auth_file(&record) } -pub(crate) fn load_or_import_auth_credentials() -> Result { - load_auth_credentials().or_else(|load_error| { - import_codex_credentials().map_err(|import_error| { - format!( - "Could not load ChatGPT credentials ({load_error}) or import Codex credentials ({import_error})." - ) - })?; - load_auth_credentials() - }) +pub(crate) async fn load_or_refresh_auth_credentials() -> Result { + if let Ok(credentials) = load_auth_credentials() { + return Ok(credentials); + } + + let _ = import_codex_credentials(); + if let Ok(credentials) = load_auth_credentials() { + return Ok(credentials); + } + + galaxy_agent_rig::refresh_chatgpt_subscription_credentials().await?; + load_auth_credentials() +} + +fn has_or_import_auth_credentials() -> Result<(), String> { + if has_auth_credentials() { + return Ok(()); + } + import_codex_credentials()?; + if has_auth_credentials() { + Ok(()) + } else { + Err("ChatGPT credentials are missing an access or refresh token.".to_string()) + } +} + +fn has_auth_credentials() -> bool { + let Some(path) = auth_file_path() else { + return false; + }; + let Ok(bytes) = std::fs::read(path) else { + return false; + }; + let Ok(record) = serde_json::from_slice::(&bytes) else { + return false; + }; + record + .refresh_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + || record.access_token.as_deref().is_some_and(|token| { + !token.trim().is_empty() + && !token_is_expired( + record + .expires_at + .or_else(|| extract_expiration_timestamp(token)), + ) + }) } fn load_auth_credentials() -> Result { @@ -277,14 +465,8 @@ fn load_auth_credentials() -> Result { let expires_at = record .expires_at .or_else(|| extract_expiration_timestamp(access_token)); - if let Some(expires_at) = expires_at { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or(0); - if now >= expires_at - 60 { - return Err("ChatGPT access token is expired".to_string()); - } + if token_is_expired(expires_at) { + return Err("ChatGPT access token is expired".to_string()); } let account_id = record @@ -307,40 +489,27 @@ fn codex_auth_file_path() -> Option { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json")) } -fn generate_random_string(len: usize) -> String { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - let mut rng = rand::thread_rng(); - (0..len) - .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) - .collect() +fn token_is_expired(expires_at: Option) -> bool { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + expires_at.is_none_or(|expires_at| now >= expires_at - 60) } -fn compute_code_challenge(verifier: &str) -> String { - let hash = Sha256::digest(verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(hash) -} - -/// Exchange the authorization code for tokens and write them to Rig's auth file. async fn exchange_code_for_tokens( code: &str, code_verifier: &str, redirect_uri: &str, ) -> Result<(), String> { - let client = reqwest::Client::new(); - - let form = [ - ("grant_type", "authorization_code"), - ("client_id", CHATGPT_CLIENT_ID), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", code_verifier), - ]; - let body = url::form_urlencoded::Serializer::new(String::new()) - .extend_pairs(form) + .append_pair("grant_type", "authorization_code") + .append_pair("client_id", CHATGPT_CLIENT_ID) + .append_pair("code", code) + .append_pair("redirect_uri", redirect_uri) + .append_pair("code_verifier", code_verifier) .finish(); - - let response = client + let response = reqwest::Client::new() .post(CHATGPT_TOKEN_URL) .header( reqwest::header::CONTENT_TYPE, @@ -349,37 +518,35 @@ async fn exchange_code_for_tokens( .body(body) .send() .await - .map_err(|e| format!("Token exchange request failed: {e}"))?; + .map_err(|error| format!("ChatGPT token exchange request failed: {error}"))?; - if !response.status().is_success() { - let status = response.status(); + let status = response.status(); + if !status.is_success() { let body = response.text().await.unwrap_or_default(); - return Err(format!("Token exchange failed: {status} {body}")); + return Err(format!("ChatGPT token exchange failed: {status} {body}")); } - - let token_response: TokenResponse = response + let tokens: TokenResponse = response .json() .await - .map_err(|e| format!("Failed to parse token response: {e}"))?; - - let access_token = token_response.access_token; - let refresh_token = token_response.refresh_token; - let id_token = token_response.id_token; - - let expires_at = extract_expiration_timestamp(&access_token); - let account_id = - extract_account_id(id_token.as_deref()).or_else(|| extract_account_id(Some(&access_token))); - - let auth_record = AuthRecord { - access_token: Some(access_token), - refresh_token, - id_token, + .map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?; + let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| { + tokens.expires_in.map(|expires_in| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default() + + expires_in as i64 + }) + }); + let account_id = extract_account_id(tokens.id_token.as_deref()) + .or_else(|| extract_account_id(Some(&tokens.access_token))); + write_auth_file(&AuthRecord { + access_token: Some(tokens.access_token), + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, expires_at, account_id, - }; - - write_auth_file(&auth_record)?; - Ok(()) + }) } fn write_auth_file(record: &AuthRecord) -> Result<(), String> { @@ -423,10 +590,22 @@ fn extract_expiration_timestamp(token: &str) -> Option { fn extract_account_id(token: Option<&str>) -> Option { let claims = decode_jwt_claims(token?); claims - .get("https://api.openai.com/auth") - .and_then(|v| v.as_object()) - .and_then(|map| map.get("chatgpt_account_id")) - .and_then(|v| v.as_str()) + .get("chatgpt_account_id") + .and_then(|value| value.as_str()) + .or_else(|| { + claims + .get("https://api.openai.com/auth") + .and_then(|value| value.get("chatgpt_account_id")) + .and_then(|value| value.as_str()) + }) + .or_else(|| { + claims + .get("organizations") + .and_then(|value| value.as_array()) + .and_then(|organizations| organizations.first()) + .and_then(|organization| organization.get("id")) + .and_then(|value| value.as_str()) + }) .map(ToOwned::to_owned) } @@ -444,6 +623,7 @@ struct TokenResponse { access_token: String, refresh_token: Option, id_token: Option, + expires_in: Option, } #[derive(serde::Deserialize, serde::Serialize)] diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 6158b59a..8369a4ba 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -1820,7 +1820,7 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] async fn discover_chatgpt_subscription_models() -> Result, String> { - let credentials = crate::ai::chatgpt_auth::load_or_import_auth_credentials()?; + let credentials = crate::ai::chatgpt_auth::load_or_refresh_auth_credentials().await?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index cdc97657..e754f113 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -63,8 +63,6 @@ use crate::ai::blocklist::agent_view::agent_input_footer::editor::{ AgentToolbarEditorMode, AgentToolbarInlineEditor, }; use crate::ai::blocklist::BlocklistAIPermissions; -#[cfg(not(target_family = "wasm"))] -use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent}; use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; #[cfg(not(target_family = "wasm"))] use crate::ai::execution_profiles::profiles::{ @@ -2969,9 +2967,6 @@ pub enum AISettingsPageAction { CreateProfile, ToggleBedrockEnabled, ToggleOpenAIEnabled, - ConnectChatGPTSubscription, - OpenChatGPTDevicePage, - CopyChatGPTDeviceCode, ToggleAcpEnabled, FetchOpenAIProviderModels(usize), AddOpenAIProvider(ProviderSetupProviderType), @@ -3690,17 +3685,6 @@ impl TypedActionView for AISettingsPageView { }); ctx.notify(); } - AISettingsPageAction::ConnectChatGPTSubscription => { - #[cfg(not(target_family = "wasm"))] - ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); - ctx.notify(); - } - AISettingsPageAction::OpenChatGPTDevicePage => { - // No-op: device-code flow removed in favor of browser OAuth. - } - AISettingsPageAction::CopyChatGPTDeviceCode => { - // No-op: device-code flow removed in favor of browser OAuth. - } AISettingsPageAction::ToggleAcpEnabled => { if cfg!(unix) { AISettings::handle(ctx).update(ctx, |settings, ctx| { diff --git a/app/src/settings_view/provider_setup_view.rs b/app/src/settings_view/provider_setup_view.rs index e8730fbc..831c6633 100644 --- a/app/src/settings_view/provider_setup_view.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -170,8 +170,6 @@ pub enum ProviderSetupViewAction { ToggleModel(usize), CycleModelCapability(usize, CapabilityKey), ConnectChatGPT, - OpenChatGPTDevicePage, - CopyChatGPTDeviceCode, SelectBedrockAuth(BedrockAuthMethod), ToggleBedrockCrossRegion, ToggleBedrockAutoLogin, @@ -2234,12 +2232,6 @@ impl TypedActionView for ProviderSetupView { #[cfg(not(target_family = "wasm"))] ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); } - ProviderSetupViewAction::OpenChatGPTDevicePage => { - // No-op: device-code flow removed in favor of browser OAuth. - } - ProviderSetupViewAction::CopyChatGPTDeviceCode => { - // No-op: device-code flow removed in favor of browser OAuth. - } ProviderSetupViewAction::SelectBedrockAuth(method) => { self.draft_bedrock.auth_method = *method; self.sync_bedrock_auth_buttons(ctx); diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index 57732989..93c2fb98 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -112,8 +112,6 @@ pub enum UriHost { TabConfig, /// Focuses a specific terminal pane by its persistent session UUID. Session, - /// Handles OAuth callbacks for ChatGPT subscription authorization. - ChatGPT, } impl FromStr for UriHost { @@ -137,7 +135,6 @@ impl FromStr for UriHost { "linear" => Ok(Self::Linear), "tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig), "session" => Ok(Self::Session), - "chatgpt" => Ok(Self::ChatGPT), _ => Err(anyhow!("Received url with unexpected host: {}", s)), } } @@ -573,13 +570,6 @@ impl UriHost { log::warn!("session deep link could not find pane with given UUID"); } } - UriHost::ChatGPT => { - #[cfg(not(target_family = "wasm"))] - { - crate::ai::chatgpt_auth::ChatGPTAuthModel::handle(ctx) - .update(ctx, |model, ctx| model.handle_oauth_callback(url, ctx)); - } - } } } @@ -605,7 +595,6 @@ impl UriHost { // Handler picks the window itself based on `?new_window=true`. Self::TabConfig => W::Nothing, Self::Session => W::Nothing, - Self::ChatGPT => W::Nothing, } } } @@ -1680,8 +1669,7 @@ fn validate_custom_uri(url: &Url) -> Result { | UriHost::Codex | UriHost::Linear | UriHost::TabConfig - | UriHost::Session - | UriHost::ChatGPT => true, + | UriHost::Session => true, // Auth and Home only allow the desktop redirect path UriHost::Auth | UriHost::Home => false, }; diff --git a/crates/galaxy_agent_rig/src/chatgpt.rs b/crates/galaxy_agent_rig/src/chatgpt.rs index 26f4b584..7c644b64 100644 --- a/crates/galaxy_agent_rig/src/chatgpt.rs +++ b/crates/galaxy_agent_rig/src/chatgpt.rs @@ -13,45 +13,16 @@ use rig_core::providers::chatgpt; use crate::request::build_completion_request; use crate::stream::start_model_turn; -/// The information a user needs to complete ChatGPT's device authorization flow. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ChatGPTDeviceCode { - pub verification_uri: String, - pub user_code: String, -} - -/// Small application-facing wrapper around Rig's native ChatGPT OAuth client. -/// -/// Keeping the Rig auth type behind this wrapper lets Galaxy present device-code -/// instructions without depending on Rig's private auth module. -pub struct ChatGPTSubscriptionClient { - client: chatgpt::Client, -} - -impl ChatGPTSubscriptionClient { - pub fn with_device_code_handler(handler: F) -> Result - where - F: Fn(ChatGPTDeviceCode) + Send + Sync + 'static, - { - let client = chatgpt::Client::builder() - .oauth() - .on_device_code(move |prompt| { - handler(ChatGPTDeviceCode { - verification_uri: prompt.verification_uri, - user_code: prompt.user_code, - }); - }) - .build() - .map_err(|error| error.to_string())?; - Ok(Self { client }) - } - - pub async fn authorize(&self) -> Result<(), String> { - self.client - .authorize() - .await - .map_err(|error| error.to_string()) - } +/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing +/// Rig to fall back to its interactive device-code flow. +pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> { + let client = chatgpt::Client::builder() + .oauth() + .allow_device_flow(false) + .originator("galaxy") + .build() + .map_err(|error| error.to_string())?; + client.authorize().await.map_err(|error| error.to_string()) } #[derive(Clone, Debug, PartialEq, Eq)] @@ -146,7 +117,10 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime { request: TurnRequest, control: TurnControl, ) -> Result { - let mut builder = chatgpt::Client::builder().oauth().allow_device_flow(false); + let mut builder = chatgpt::Client::builder() + .oauth() + .allow_device_flow(false) + .originator("galaxy"); if let Some(auth_file) = &self.config.auth_file { builder = builder.auth_file(auth_file); } diff --git a/crates/galaxy_graphql_schema/package.json b/crates/galaxy_graphql_schema/package.json index 7ecd42a2..69387dc1 100644 --- a/crates/galaxy_graphql_schema/package.json +++ b/crates/galaxy_graphql_schema/package.json @@ -1,5 +1,6 @@ { "private": true, + "packageManager": "yarn@1.22.22", "scripts": { "generate": "graphql-codegen -r ts-node/register" }, diff --git a/script/build-and-deploy-hermes/package.json b/script/build-and-deploy-hermes/package.json index 7a79e50d..ce7cd88d 100644 --- a/script/build-and-deploy-hermes/package.json +++ b/script/build-and-deploy-hermes/package.json @@ -2,6 +2,7 @@ "name": "build-and-deploy-hermes", "version": "1.0.0", "private": true, + "packageManager": "yarn@1.22.22", "type": "module", "scripts": { "build": "tsx src/index.tsx", diff --git a/script/build-and-install-to-applications/package.json b/script/build-and-install-to-applications/package.json index 8bb64431..1ca7439a 100644 --- a/script/build-and-install-to-applications/package.json +++ b/script/build-and-install-to-applications/package.json @@ -2,6 +2,7 @@ "name": "build-and-install-to-applications", "version": "1.0.0", "private": true, + "packageManager": "yarn@1.22.22", "type": "module", "scripts": { "start": "tsx src/index.tsx"