Add browser OAuth for ChatGPT subscriptions
This commit is contained in:
+382
-202
@@ -1,24 +1,26 @@
|
|||||||
//! ChatGPT subscription browser OAuth state used by the AI settings page.
|
//! 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),
|
use std::io::{BufRead, BufReader, Write};
|
||||||
//! this module implements a standard OAuth 2.0 Authorization Code + PKCE flow:
|
use std::net::{TcpListener, TcpStream};
|
||||||
//! 1. Open the browser to OpenAI's authorize endpoint
|
use std::time::Duration;
|
||||||
//! 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 base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use galaxy_core::channel::ChannelState;
|
use futures::channel::oneshot;
|
||||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||||
use rand::Rng;
|
use instant::Instant;
|
||||||
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/api/accounts/authorize";
|
const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
|
||||||
const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/oauth/token";
|
const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
|
||||||
const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||||
const CHATGPT_SCOPES: &str = "openid profile email offline_access";
|
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.
|
/// Current state of the local ChatGPT subscription connection.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -35,19 +37,20 @@ pub(crate) enum ChatGPTAuthModelEvent {
|
|||||||
StateChanged,
|
StateChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct OAuthCallback {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
|
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
|
||||||
pub(crate) struct ChatGPTAuthModel {
|
pub(crate) struct ChatGPTAuthModel {
|
||||||
state: ChatGPTAuthState,
|
state: ChatGPTAuthState,
|
||||||
/// PKCE code verifier stored between authorize and callback.
|
|
||||||
pending_code_verifier: Option<String>,
|
|
||||||
/// CSRF state token stored between authorize and callback.
|
|
||||||
pending_state: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatGPTAuthModel {
|
impl ChatGPTAuthModel {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
let state = match load_or_import_auth_credentials() {
|
let state = match has_or_import_auth_credentials() {
|
||||||
Ok(_) => ChatGPTAuthState::Connected,
|
Ok(()) => ChatGPTAuthState::Connected,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}"
|
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}"
|
||||||
@@ -55,18 +58,14 @@ impl ChatGPTAuthModel {
|
|||||||
ChatGPTAuthState::NotConnected
|
ChatGPTAuthState::NotConnected
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Self {
|
Self { state }
|
||||||
state,
|
|
||||||
pending_code_verifier: None,
|
|
||||||
pending_state: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn state(&self) -> &ChatGPTAuthState {
|
pub(crate) fn state(&self) -> &ChatGPTAuthState {
|
||||||
&self.state
|
&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<Self>) {
|
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
if matches!(
|
if matches!(
|
||||||
self.state,
|
self.state,
|
||||||
@@ -75,105 +74,75 @@ impl ChatGPTAuthModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to import credentials from ~/.codex/auth.json first.
|
if has_or_import_auth_credentials().is_ok() {
|
||||||
if let Ok(()) = import_codex_credentials() {
|
|
||||||
self.state = ChatGPTAuthState::Connected;
|
self.state = ChatGPTAuthState::Connected;
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing credentials — start the browser OAuth flow.
|
let (redirect_uri, callback_rx) = match start_oauth_callback_server() {
|
||||||
let code_verifier = generate_random_string(64);
|
Ok(server) => server,
|
||||||
let code_challenge = compute_code_challenge(&code_verifier);
|
Err(error) => {
|
||||||
let state = generate_random_string(32);
|
self.fail(error, ctx);
|
||||||
let redirect_uri = chatgpt_redirect_uri();
|
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;
|
self.state = ChatGPTAuthState::AwaitingBrowser;
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
ctx.open_url(authorize_url.as_str());
|
||||||
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<Self>) {
|
|
||||||
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();
|
|
||||||
|
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
async move { exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await },
|
async move {
|
||||||
|model, result, ctx| match result {
|
callback_rx.await.map_err(|_| {
|
||||||
Ok(()) => {
|
"ChatGPT OAuth callback server stopped unexpectedly.".to_string()
|
||||||
model.state = ChatGPTAuthState::Connected;
|
})?
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
},
|
||||||
}
|
move |model, callback, ctx| {
|
||||||
Err(error) => {
|
let callback = match callback {
|
||||||
model.fail(&error, ctx);
|
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>) {
|
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
|
||||||
self.state = ChatGPTAuthState::Failed(message.to_string());
|
self.state = ChatGPTAuthState::Failed(error);
|
||||||
self.pending_code_verifier = None;
|
|
||||||
self.pending_state = None;
|
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,12 +153,202 @@ impl Entity for ChatGPTAuthModel {
|
|||||||
|
|
||||||
impl SingletonEntity for ChatGPTAuthModel {}
|
impl SingletonEntity for ChatGPTAuthModel {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
fn build_authorize_url(
|
||||||
// Helpers
|
redirect_uri: &str,
|
||||||
// ---------------------------------------------------------------------------
|
code_challenge: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<Url, String> {
|
||||||
|
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 {
|
fn random_urlsafe_string(byte_count: usize) -> String {
|
||||||
format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme())
|
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<Result<OAuthCallback, String>>), 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<OAuthCallback, String> {
|
||||||
|
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<OAuthCallback, String> {
|
||||||
|
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#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>__TITLE__ - Galaxy</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#10141b;color:#f2f5f7;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:420px;margin:24px;padding:36px;border:1px solid #303846;border-radius:14px;background:#181e28;text-align:center;box-shadow:0 18px 60px #0008}i{display:block;width:48px;height:48px;margin:0 auto 22px;border-radius:50%;background:__ACCENT__}h1{font-size:22px;margin:0 0 12px}p{margin:0;color:#aeb8c6;line-height:1.55}small{display:block;margin-top:24px;color:#697587;letter-spacing:.08em;text-transform:uppercase}</style></head><body><main><i></i><h1>__TITLE__</h1><p>__MESSAGE__</p><small>Galaxy</small></main></body></html>"#
|
||||||
|
.replace("__TITLE__", title)
|
||||||
|
.replace("__MESSAGE__", message)
|
||||||
|
.replace("__ACCENT__", accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[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 tokens = doc.get("tokens").ok_or("No tokens object")?;
|
||||||
let access_token = tokens
|
let access_token = tokens
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|token| !token.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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let refresh_token = tokens
|
let refresh_token = tokens
|
||||||
.get("refresh_token")
|
.get("refresh_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|value| value.as_str())
|
||||||
|
.filter(|token| !token.is_empty())
|
||||||
.map(ToOwned::to_owned);
|
.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
|
let id_token = tokens
|
||||||
.get("id_token")
|
.get("id_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -238,10 +387,10 @@ pub(crate) fn import_codex_credentials() -> Result<(), String> {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.or_else(|| extract_account_id(id_token.as_deref()))
|
.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 {
|
let record = AuthRecord {
|
||||||
access_token: Some(access_token.to_owned()),
|
access_token: access_token.map(ToOwned::to_owned),
|
||||||
refresh_token,
|
refresh_token,
|
||||||
id_token,
|
id_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -251,15 +400,54 @@ pub(crate) fn import_codex_credentials() -> Result<(), String> {
|
|||||||
write_auth_file(&record)
|
write_auth_file(&record)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_or_import_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
||||||
load_auth_credentials().or_else(|load_error| {
|
if let Ok(credentials) = load_auth_credentials() {
|
||||||
import_codex_credentials().map_err(|import_error| {
|
return Ok(credentials);
|
||||||
format!(
|
}
|
||||||
"Could not load ChatGPT credentials ({load_error}) or import Codex credentials ({import_error})."
|
|
||||||
)
|
let _ = import_codex_credentials();
|
||||||
})?;
|
if let Ok(credentials) = load_auth_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::<AuthRecord>(&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<ChatGPTAuthCredentials, String> {
|
fn load_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
||||||
@@ -277,14 +465,8 @@ fn load_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
|||||||
let expires_at = record
|
let expires_at = record
|
||||||
.expires_at
|
.expires_at
|
||||||
.or_else(|| extract_expiration_timestamp(access_token));
|
.or_else(|| extract_expiration_timestamp(access_token));
|
||||||
if let Some(expires_at) = expires_at {
|
if token_is_expired(expires_at) {
|
||||||
let now = std::time::SystemTime::now()
|
return Err("ChatGPT access token is expired".to_string());
|
||||||
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let account_id = record
|
let account_id = record
|
||||||
@@ -307,40 +489,27 @@ fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
|||||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_random_string(len: usize) -> String {
|
fn token_is_expired(expires_at: Option<i64>) -> bool {
|
||||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
let now = std::time::SystemTime::now()
|
||||||
let mut rng = rand::thread_rng();
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
(0..len)
|
.map(|duration| duration.as_secs() as i64)
|
||||||
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
.unwrap_or_default();
|
||||||
.collect()
|
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(
|
async fn exchange_code_for_tokens(
|
||||||
code: &str,
|
code: &str,
|
||||||
code_verifier: &str,
|
code_verifier: &str,
|
||||||
redirect_uri: &str,
|
redirect_uri: &str,
|
||||||
) -> Result<(), String> {
|
) -> 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())
|
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();
|
.finish();
|
||||||
|
let response = reqwest::Client::new()
|
||||||
let response = client
|
|
||||||
.post(CHATGPT_TOKEN_URL)
|
.post(CHATGPT_TOKEN_URL)
|
||||||
.header(
|
.header(
|
||||||
reqwest::header::CONTENT_TYPE,
|
reqwest::header::CONTENT_TYPE,
|
||||||
@@ -349,37 +518,35 @@ async fn exchange_code_for_tokens(
|
|||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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();
|
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 tokens: TokenResponse = response
|
||||||
let token_response: TokenResponse = response
|
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse token response: {e}"))?;
|
.map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?;
|
||||||
|
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
|
||||||
let access_token = token_response.access_token;
|
tokens.expires_in.map(|expires_in| {
|
||||||
let refresh_token = token_response.refresh_token;
|
std::time::SystemTime::now()
|
||||||
let id_token = token_response.id_token;
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
let expires_at = extract_expiration_timestamp(&access_token);
|
.unwrap_or_default()
|
||||||
let account_id =
|
+ expires_in as i64
|
||||||
extract_account_id(id_token.as_deref()).or_else(|| extract_account_id(Some(&access_token)));
|
})
|
||||||
|
});
|
||||||
let auth_record = AuthRecord {
|
let account_id = extract_account_id(tokens.id_token.as_deref())
|
||||||
access_token: Some(access_token),
|
.or_else(|| extract_account_id(Some(&tokens.access_token)));
|
||||||
refresh_token,
|
write_auth_file(&AuthRecord {
|
||||||
id_token,
|
access_token: Some(tokens.access_token),
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
id_token: tokens.id_token,
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id,
|
account_id,
|
||||||
};
|
})
|
||||||
|
|
||||||
write_auth_file(&auth_record)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
||||||
@@ -423,10 +590,22 @@ fn extract_expiration_timestamp(token: &str) -> Option<i64> {
|
|||||||
fn extract_account_id(token: Option<&str>) -> Option<String> {
|
fn extract_account_id(token: Option<&str>) -> Option<String> {
|
||||||
let claims = decode_jwt_claims(token?);
|
let claims = decode_jwt_claims(token?);
|
||||||
claims
|
claims
|
||||||
.get("https://api.openai.com/auth")
|
.get("chatgpt_account_id")
|
||||||
.and_then(|v| v.as_object())
|
.and_then(|value| value.as_str())
|
||||||
.and_then(|map| map.get("chatgpt_account_id"))
|
.or_else(|| {
|
||||||
.and_then(|v| v.as_str())
|
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)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +623,7 @@ struct TokenResponse {
|
|||||||
access_token: String,
|
access_token: String,
|
||||||
refresh_token: Option<String>,
|
refresh_token: Option<String>,
|
||||||
id_token: Option<String>,
|
id_token: Option<String>,
|
||||||
|
expires_in: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
|
|||||||
+1
-1
@@ -1820,7 +1820,7 @@ impl LLMPreferences {
|
|||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
async fn discover_chatgpt_subscription_models() -> Result<Vec<OpenAIModelConfig>, String> {
|
async fn discover_chatgpt_subscription_models() -> Result<Vec<OpenAIModelConfig>, 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()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ use crate::ai::blocklist::agent_view::agent_input_footer::editor::{
|
|||||||
AgentToolbarEditorMode, AgentToolbarInlineEditor,
|
AgentToolbarEditorMode, AgentToolbarInlineEditor,
|
||||||
};
|
};
|
||||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
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;
|
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::ai::execution_profiles::profiles::{
|
use crate::ai::execution_profiles::profiles::{
|
||||||
@@ -2969,9 +2967,6 @@ pub enum AISettingsPageAction {
|
|||||||
CreateProfile,
|
CreateProfile,
|
||||||
ToggleBedrockEnabled,
|
ToggleBedrockEnabled,
|
||||||
ToggleOpenAIEnabled,
|
ToggleOpenAIEnabled,
|
||||||
ConnectChatGPTSubscription,
|
|
||||||
OpenChatGPTDevicePage,
|
|
||||||
CopyChatGPTDeviceCode,
|
|
||||||
ToggleAcpEnabled,
|
ToggleAcpEnabled,
|
||||||
FetchOpenAIProviderModels(usize),
|
FetchOpenAIProviderModels(usize),
|
||||||
AddOpenAIProvider(ProviderSetupProviderType),
|
AddOpenAIProvider(ProviderSetupProviderType),
|
||||||
@@ -3690,17 +3685,6 @@ impl TypedActionView for AISettingsPageView {
|
|||||||
});
|
});
|
||||||
ctx.notify();
|
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 => {
|
AISettingsPageAction::ToggleAcpEnabled => {
|
||||||
if cfg!(unix) {
|
if cfg!(unix) {
|
||||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||||
|
|||||||
@@ -170,8 +170,6 @@ pub enum ProviderSetupViewAction {
|
|||||||
ToggleModel(usize),
|
ToggleModel(usize),
|
||||||
CycleModelCapability(usize, CapabilityKey),
|
CycleModelCapability(usize, CapabilityKey),
|
||||||
ConnectChatGPT,
|
ConnectChatGPT,
|
||||||
OpenChatGPTDevicePage,
|
|
||||||
CopyChatGPTDeviceCode,
|
|
||||||
SelectBedrockAuth(BedrockAuthMethod),
|
SelectBedrockAuth(BedrockAuthMethod),
|
||||||
ToggleBedrockCrossRegion,
|
ToggleBedrockCrossRegion,
|
||||||
ToggleBedrockAutoLogin,
|
ToggleBedrockAutoLogin,
|
||||||
@@ -2234,12 +2232,6 @@ impl TypedActionView for ProviderSetupView {
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
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) => {
|
ProviderSetupViewAction::SelectBedrockAuth(method) => {
|
||||||
self.draft_bedrock.auth_method = *method;
|
self.draft_bedrock.auth_method = *method;
|
||||||
self.sync_bedrock_auth_buttons(ctx);
|
self.sync_bedrock_auth_buttons(ctx);
|
||||||
|
|||||||
+1
-13
@@ -112,8 +112,6 @@ pub enum UriHost {
|
|||||||
TabConfig,
|
TabConfig,
|
||||||
/// Focuses a specific terminal pane by its persistent session UUID.
|
/// Focuses a specific terminal pane by its persistent session UUID.
|
||||||
Session,
|
Session,
|
||||||
/// Handles OAuth callbacks for ChatGPT subscription authorization.
|
|
||||||
ChatGPT,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for UriHost {
|
impl FromStr for UriHost {
|
||||||
@@ -137,7 +135,6 @@ impl FromStr for UriHost {
|
|||||||
"linear" => Ok(Self::Linear),
|
"linear" => Ok(Self::Linear),
|
||||||
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
|
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
|
||||||
"session" => Ok(Self::Session),
|
"session" => Ok(Self::Session),
|
||||||
"chatgpt" => Ok(Self::ChatGPT),
|
|
||||||
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
|
_ => 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");
|
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`.
|
// Handler picks the window itself based on `?new_window=true`.
|
||||||
Self::TabConfig => W::Nothing,
|
Self::TabConfig => W::Nothing,
|
||||||
Self::Session => W::Nothing,
|
Self::Session => W::Nothing,
|
||||||
Self::ChatGPT => W::Nothing,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1680,8 +1669,7 @@ fn validate_custom_uri(url: &Url) -> Result<UriHost> {
|
|||||||
| UriHost::Codex
|
| UriHost::Codex
|
||||||
| UriHost::Linear
|
| UriHost::Linear
|
||||||
| UriHost::TabConfig
|
| UriHost::TabConfig
|
||||||
| UriHost::Session
|
| UriHost::Session => true,
|
||||||
| UriHost::ChatGPT => true,
|
|
||||||
// Auth and Home only allow the desktop redirect path
|
// Auth and Home only allow the desktop redirect path
|
||||||
UriHost::Auth | UriHost::Home => false,
|
UriHost::Auth | UriHost::Home => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,45 +13,16 @@ use rig_core::providers::chatgpt;
|
|||||||
use crate::request::build_completion_request;
|
use crate::request::build_completion_request;
|
||||||
use crate::stream::start_model_turn;
|
use crate::stream::start_model_turn;
|
||||||
|
|
||||||
/// The information a user needs to complete ChatGPT's device authorization flow.
|
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
/// Rig to fall back to its interactive device-code flow.
|
||||||
pub struct ChatGPTDeviceCode {
|
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
|
||||||
pub verification_uri: String,
|
let client = chatgpt::Client::builder()
|
||||||
pub user_code: String,
|
.oauth()
|
||||||
}
|
.allow_device_flow(false)
|
||||||
|
.originator("galaxy")
|
||||||
/// Small application-facing wrapper around Rig's native ChatGPT OAuth client.
|
.build()
|
||||||
///
|
.map_err(|error| error.to_string())?;
|
||||||
/// Keeping the Rig auth type behind this wrapper lets Galaxy present device-code
|
client.authorize().await.map_err(|error| error.to_string())
|
||||||
/// instructions without depending on Rig's private auth module.
|
|
||||||
pub struct ChatGPTSubscriptionClient {
|
|
||||||
client: chatgpt::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChatGPTSubscriptionClient {
|
|
||||||
pub fn with_device_code_handler<F>(handler: F) -> Result<Self, String>
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -146,7 +117,10 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
|||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
control: TurnControl,
|
control: TurnControl,
|
||||||
) -> Result<AgentEventStream, AgentError> {
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
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 {
|
if let Some(auth_file) = &self.config.auth_file {
|
||||||
builder = builder.auth_file(auth_file);
|
builder = builder.auth_file(auth_file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "graphql-codegen -r ts-node/register"
|
"generate": "graphql-codegen -r ts-node/register"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "build-and-deploy-hermes",
|
"name": "build-and-deploy-hermes",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsx src/index.tsx",
|
"build": "tsx src/index.tsx",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "build-and-install-to-applications",
|
"name": "build-and-install-to-applications",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "tsx src/index.tsx"
|
"start": "tsx src/index.tsx"
|
||||||
|
|||||||
Reference in New Issue
Block a user