Update ChatGPT authentication and sign-in recovery
Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
+356
-224
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
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 futures::channel::oneshot;
|
use futures::channel::oneshot;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use galaxy_agent_rig::ChatGPTCredentialResolver;
|
||||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||||
|
use galaxyui_extras::secure_storage::AppContextExt as _;
|
||||||
use instant::Instant;
|
use instant::Instant;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -21,6 +26,10 @@ const CALLBACK_HOST: &str = "localhost";
|
|||||||
const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
|
const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
|
||||||
const CALLBACK_PATH: &str = "/auth/callback";
|
const CALLBACK_PATH: &str = "/auth/callback";
|
||||||
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
|
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
|
||||||
|
const AUTH_STORAGE_KEY: &str = "galaxy.chatgpt.oauth";
|
||||||
|
const SIGN_IN_REQUIRED: &str = "ChatGPT sign-in required. Connect or reconnect ChatGPT in Providers.";
|
||||||
|
type Reply = oneshot::Sender<Result<ChatGPTAuthCredentials, String>>;
|
||||||
|
static RESOLVER: OnceLock<Mutex<Option<async_channel::Sender<Reply>>>> = OnceLock::new();
|
||||||
|
|
||||||
/// 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)]
|
||||||
@@ -45,26 +54,42 @@ struct OAuthCallback {
|
|||||||
/// 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,
|
||||||
|
record: Option<AuthRecord>,
|
||||||
|
refresh: RefreshCoordinator,
|
||||||
|
sign_in_generation: u64,
|
||||||
|
sign_in_cancel: Option<Arc<AtomicBool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatGPTAuthModel {
|
impl ChatGPTAuthModel {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||||
let state = match has_or_import_auth_credentials() {
|
let (tx, rx) = async_channel::bounded(64);
|
||||||
Ok(()) => ChatGPTAuthState::Connected,
|
*RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
|
||||||
Err(error) => {
|
ctx.spawn_stream_local(rx, |model, reply, ctx| model.resolve(reply, ctx), |_, _| {});
|
||||||
log::debug!(
|
let record = ctx.secure_storage().read_value(AUTH_STORAGE_KEY).ok()
|
||||||
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}"
|
.and_then(|json| serde_json::from_str::<AuthRecord>(&json).ok())
|
||||||
);
|
.filter(|record| record.credentials(now()).is_ok() || record.can_refresh());
|
||||||
ChatGPTAuthState::NotConnected
|
let state = if record.is_some() {
|
||||||
}
|
ChatGPTAuthState::Connected
|
||||||
|
} else {
|
||||||
|
ChatGPTAuthState::NotConnected
|
||||||
};
|
};
|
||||||
Self { state }
|
Self {
|
||||||
|
state,
|
||||||
|
record,
|
||||||
|
refresh: RefreshCoordinator::default(),
|
||||||
|
sign_in_generation: 0,
|
||||||
|
sign_in_cancel: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn new_for_test() -> Self {
|
pub(crate) fn new_for_test() -> Self {
|
||||||
Self {
|
Self {
|
||||||
state: ChatGPTAuthState::NotConnected,
|
state: ChatGPTAuthState::NotConnected,
|
||||||
|
record: None,
|
||||||
|
refresh: RefreshCoordinator::default(),
|
||||||
|
sign_in_generation: 0,
|
||||||
|
sign_in_cancel: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,22 +97,26 @@ impl ChatGPTAuthModel {
|
|||||||
&self.state
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reuses persisted credentials when possible, otherwise starts browser OAuth with PKCE.
|
fn begin_sign_in(&mut self) -> Option<u64> {
|
||||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
|
||||||
if matches!(
|
if matches!(
|
||||||
self.state,
|
self.state,
|
||||||
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||||
) {
|
) {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_or_import_auth_credentials().is_ok() {
|
self.refresh.invalidate();
|
||||||
self.state = ChatGPTAuthState::Connected;
|
self.sign_in_generation += 1;
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
Some(self.sign_in_generation)
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let (redirect_uri, callback_rx) = match start_oauth_callback_server() {
|
/// Explicit connection always starts a fresh browser OAuth flow with PKCE.
|
||||||
|
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
|
let Some(generation) = self.begin_sign_in() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let (redirect_uri, callback_rx) = match start_oauth_callback_server(cancelled.clone()) {
|
||||||
Ok(server) => server,
|
Ok(server) => server,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.fail(error, ctx);
|
self.fail(error, ctx);
|
||||||
@@ -106,8 +135,8 @@ impl ChatGPTAuthModel {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state = ChatGPTAuthState::AwaitingBrowser;
|
self.sign_in_cancel = Some(cancelled);
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
self.set_state(ChatGPTAuthState::AwaitingBrowser, ctx);
|
||||||
ctx.open_url(authorize_url.as_str());
|
ctx.open_url(authorize_url.as_str());
|
||||||
|
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
@@ -117,6 +146,9 @@ impl ChatGPTAuthModel {
|
|||||||
})?
|
})?
|
||||||
},
|
},
|
||||||
move |model, callback, ctx| {
|
move |model, callback, ctx| {
|
||||||
|
if model.sign_in_generation != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let callback = match callback {
|
let callback = match callback {
|
||||||
Ok(callback) => callback,
|
Ok(callback) => callback,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -129,19 +161,27 @@ impl ChatGPTAuthModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
model.state = ChatGPTAuthState::ExchangingToken;
|
model.set_state(ChatGPTAuthState::ExchangingToken, ctx);
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
async move {
|
async move {
|
||||||
exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri)
|
exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri)
|
||||||
.await
|
.await
|
||||||
},
|
},
|
||||||
|model, result, ctx| match result {
|
move |model, result, ctx| {
|
||||||
Ok(()) => {
|
if model.sign_in_generation != generation {
|
||||||
model.state = ChatGPTAuthState::Connected;
|
return;
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
}
|
||||||
|
match result.and_then(|record| {
|
||||||
|
save_auth_record(&record, ctx)?;
|
||||||
|
Ok(record)
|
||||||
|
}) {
|
||||||
|
Ok(record) => {
|
||||||
|
model.record = Some(record);
|
||||||
|
model.sign_in_cancel = None;
|
||||||
|
model.set_state(ChatGPTAuthState::Connected, ctx);
|
||||||
|
}
|
||||||
|
Err(error) => model.fail(error, ctx),
|
||||||
}
|
}
|
||||||
Err(error) => model.fail(error, ctx),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -149,8 +189,77 @@ impl ChatGPTAuthModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
|
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
|
||||||
self.state = ChatGPTAuthState::Failed(error);
|
if let Some(cancelled) = self.sign_in_cancel.take() {
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
cancelled.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
self.set_state(ChatGPTAuthState::Failed(error), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_state(&mut self, state: ChatGPTAuthState, ctx: &mut ModelContext<Self>) {
|
||||||
|
if self.state != state {
|
||||||
|
self.state = state;
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cancel_sign_in(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
|
if let Some(cancelled) = self.sign_in_cancel.take() {
|
||||||
|
cancelled.store(true, Ordering::SeqCst);
|
||||||
|
self.sign_in_generation += 1;
|
||||||
|
self.refresh.invalidate();
|
||||||
|
self.set_state(ChatGPTAuthState::NotConnected, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&mut self, reply: Reply, ctx: &mut ModelContext<Self>) {
|
||||||
|
if reply.is_canceled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(self.state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken) {
|
||||||
|
let _ = reply.send(Err("ChatGPT sign-in is in progress.".to_owned()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(record) = self.record.as_ref() else {
|
||||||
|
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Ok(credentials) = record.credentials(now()) {
|
||||||
|
let _ = reply.send(Ok(credentials));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !record.can_refresh() {
|
||||||
|
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
|
||||||
|
self.fail(SIGN_IN_REQUIRED.to_owned(), ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let record = record.clone();
|
||||||
|
let Some(generation) = self.refresh.begin(reply) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = ctx.spawn(
|
||||||
|
async move { refresh_tokens(&record).await },
|
||||||
|
move |model, result, ctx| {
|
||||||
|
let completed = model.refresh.complete(generation, result, |record| {
|
||||||
|
save_auth_record(record, ctx)
|
||||||
|
});
|
||||||
|
match completed {
|
||||||
|
None => {}
|
||||||
|
Some(Ok(record)) => {
|
||||||
|
model.record = Some(record);
|
||||||
|
model.set_state(ChatGPTAuthState::Connected, ctx);
|
||||||
|
}
|
||||||
|
Some(Err(error)) => {
|
||||||
|
if error.reauthenticate {
|
||||||
|
model.record = None;
|
||||||
|
if save_auth_record(&AuthRecord::default(), ctx).is_err() {
|
||||||
|
log::warn!("[chatgpt/auth] Could not persist reconnect-required state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.fail(error.message, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +303,7 @@ fn random_hex_string(byte_count: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_oauth_callback_server(
|
fn start_oauth_callback_server(
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> {
|
) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> {
|
||||||
let mut bind_errors = Vec::new();
|
let mut bind_errors = Vec::new();
|
||||||
let mut bound = None;
|
let mut bound = None;
|
||||||
@@ -222,6 +332,10 @@ fn start_oauth_callback_server(
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let deadline = Instant::now() + CALLBACK_TIMEOUT;
|
let deadline = Instant::now() + CALLBACK_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
|
if cancelled.load(Ordering::SeqCst) {
|
||||||
|
let _ = callback_tx.send(Err("ChatGPT sign-in was cancelled.".to_owned()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
match listener.accept() {
|
match listener.accept() {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let _ = callback_tx.send(handle_callback_connection(stream));
|
let _ = callback_tx.send(handle_callback_connection(stream));
|
||||||
@@ -323,26 +437,19 @@ fn parse_callback_request_line(request_line: &str) -> Result<OAuthCallback, Stri
|
|||||||
|
|
||||||
let mut code = None;
|
let mut code = None;
|
||||||
let mut state = None;
|
let mut state = None;
|
||||||
let mut oauth_error = None;
|
let mut oauth_error = false;
|
||||||
let mut error_description = None;
|
|
||||||
for (key, value) in url.query_pairs() {
|
for (key, value) in url.query_pairs() {
|
||||||
match key.as_ref() {
|
match key.as_ref() {
|
||||||
"code" if !value.is_empty() => code = Some(value.into_owned()),
|
"code" if !value.is_empty() => code = Some(value.into_owned()),
|
||||||
"state" if !value.is_empty() => state = 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" if !value.is_empty() => oauth_error = true,
|
||||||
"error_description" if !value.is_empty() => {
|
|
||||||
error_description = Some(value.into_owned())
|
|
||||||
}
|
|
||||||
"code" | "state" | "error" | "error_description" => {}
|
"code" | "state" | "error" | "error_description" => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(error) = oauth_error {
|
if oauth_error {
|
||||||
return Err(format!(
|
return Err("ChatGPT browser authorization failed. Please try signing in again.".to_owned());
|
||||||
"ChatGPT authorization failed: {error} ({})",
|
|
||||||
error_description.as_deref().unwrap_or("no description")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(OAuthCallback {
|
Ok(OAuthCallback {
|
||||||
code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?,
|
code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?,
|
||||||
@@ -358,157 +465,101 @@ fn callback_response_page(title: &str, message: &str, is_error: bool) -> String
|
|||||||
.replace("__ACCENT__", accent)
|
.replace("__ACCENT__", accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
pub(crate) use galaxy_agent_rig::ChatGPTSubscriptionCredentials as ChatGPTAuthCredentials;
|
||||||
pub(crate) struct ChatGPTAuthCredentials {
|
|
||||||
pub(crate) access_token: String,
|
|
||||||
pub(crate) account_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file.
|
pub(crate) fn credential_resolver() -> ChatGPTCredentialResolver {
|
||||||
/// Returns `Ok(())` if credentials were found and successfully imported.
|
Arc::new(|| Box::pin(load_or_refresh_auth_credentials()))
|
||||||
pub(crate) fn import_codex_credentials() -> Result<(), String> {
|
|
||||||
let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?;
|
|
||||||
let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?;
|
|
||||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?;
|
|
||||||
|
|
||||||
let tokens = doc.get("tokens").ok_or("No tokens object")?;
|
|
||||||
let access_token = tokens
|
|
||||||
.get("access_token")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.filter(|token| !token.is_empty());
|
|
||||||
let refresh_token = tokens
|
|
||||||
.get("refresh_token")
|
|
||||||
.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())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let account_id = tokens
|
|
||||||
.get("account_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.or_else(|| extract_account_id(id_token.as_deref()))
|
|
||||||
.or_else(|| extract_account_id(access_token));
|
|
||||||
|
|
||||||
let record = AuthRecord {
|
|
||||||
access_token: access_token.map(ToOwned::to_owned),
|
|
||||||
refresh_token,
|
|
||||||
id_token,
|
|
||||||
expires_at,
|
|
||||||
account_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
write_auth_file(&record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
||||||
if let Ok(credentials) = load_auth_credentials() {
|
let (tx, rx) = oneshot::channel();
|
||||||
return Ok(credentials);
|
RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap()
|
||||||
}
|
.as_ref().ok_or("ChatGPT authorization service is unavailable.")?
|
||||||
|
.try_send(tx).map_err(|_| "ChatGPT authorization service is busy or unavailable.")?;
|
||||||
let _ = import_codex_credentials();
|
match futures::future::select(
|
||||||
if let Ok(credentials) = load_auth_credentials() {
|
Box::pin(rx),
|
||||||
return Ok(credentials);
|
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
|
||||||
}
|
).await {
|
||||||
|
futures::future::Either::Left((result, _)) => {
|
||||||
galaxy_agent_rig::refresh_chatgpt_subscription_credentials().await?;
|
result.map_err(|_| "ChatGPT authorization service stopped.".to_owned())?
|
||||||
load_auth_credentials()
|
}
|
||||||
}
|
futures::future::Either::Right(_) => {
|
||||||
|
Err("ChatGPT credential refresh timed out. Retry or sign in again.".to_owned())
|
||||||
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 {
|
#[derive(Clone, Debug)]
|
||||||
let Some(path) = auth_file_path() else {
|
struct AuthFailure {
|
||||||
return false;
|
message: String,
|
||||||
};
|
reauthenticate: bool,
|
||||||
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> {
|
#[derive(Default)]
|
||||||
let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?;
|
struct RefreshCoordinator {
|
||||||
let bytes = std::fs::read(&path)
|
generation: u64,
|
||||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
active: Option<(u64, Vec<Reply>)>,
|
||||||
let record: AuthRecord = serde_json::from_slice(&bytes)
|
}
|
||||||
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
|
|
||||||
let access_token = record
|
|
||||||
.access_token
|
|
||||||
.as_deref()
|
|
||||||
.filter(|token| !token.trim().is_empty())
|
|
||||||
.ok_or("ChatGPT auth file does not contain an access token")?;
|
|
||||||
|
|
||||||
let expires_at = record
|
impl RefreshCoordinator {
|
||||||
.expires_at
|
fn begin(&mut self, reply: Reply) -> Option<u64> {
|
||||||
.or_else(|| extract_expiration_timestamp(access_token));
|
if let Some((_, replies)) = &mut self.active {
|
||||||
if token_is_expired(expires_at) {
|
replies.push(reply);
|
||||||
return Err("ChatGPT access token is expired".to_string());
|
return None;
|
||||||
|
}
|
||||||
|
self.generation += 1;
|
||||||
|
self.active = Some((self.generation, vec![reply]));
|
||||||
|
Some(self.generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
let account_id = record
|
fn invalidate(&mut self) {
|
||||||
.account_id
|
self.generation += 1;
|
||||||
.clone()
|
if let Some((_, replies)) = self.active.take() {
|
||||||
.filter(|account_id| !account_id.trim().is_empty())
|
for reply in replies {
|
||||||
.or_else(|| extract_account_id(record.id_token.as_deref()))
|
let _ = reply.send(Err("ChatGPT session was replaced. Please sign in again.".to_owned()));
|
||||||
.or_else(|| extract_account_id(Some(access_token)));
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(ChatGPTAuthCredentials {
|
fn complete(
|
||||||
access_token: access_token.to_string(),
|
&mut self,
|
||||||
account_id,
|
generation: u64,
|
||||||
})
|
result: Result<AuthRecord, AuthFailure>,
|
||||||
}
|
persist: impl FnOnce(&AuthRecord) -> Result<(), String>,
|
||||||
|
) -> Option<Result<AuthRecord, AuthFailure>> {
|
||||||
fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
if self.active.as_ref().is_none_or(|(active, _)| *active != generation) {
|
||||||
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
return None;
|
||||||
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
}
|
||||||
|
let (_, replies) = self.active.take()?;
|
||||||
|
let result = result.and_then(|record| {
|
||||||
|
let credentials = record.access_context().map_err(|message| AuthFailure {
|
||||||
|
message, reauthenticate: true,
|
||||||
|
})?;
|
||||||
|
persist(&record).map_err(|_| AuthFailure {
|
||||||
|
message: "Could not securely save refreshed ChatGPT credentials. Please sign in again.".to_owned(),
|
||||||
|
reauthenticate: true,
|
||||||
|
})?;
|
||||||
|
Ok((record, credentials))
|
||||||
|
});
|
||||||
|
for reply in replies {
|
||||||
|
let response = result.as_ref().map(|(_, credentials)| credentials.clone())
|
||||||
|
.map_err(|error| error.message.clone());
|
||||||
|
let _ = reply.send(response);
|
||||||
|
}
|
||||||
|
Some(result.map(|(record, _)| record))
|
||||||
}
|
}
|
||||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_is_expired(expires_at: Option<i64>) -> bool {
|
fn now() -> i64 {
|
||||||
let now = std::time::SystemTime::now()
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.map(|duration| duration.as_secs() as i64).unwrap_or_default()
|
||||||
.map(|duration| duration.as_secs() as i64)
|
|
||||||
.unwrap_or_default();
|
|
||||||
expires_at.is_none_or(|expires_at| now >= expires_at - 60)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<AuthRecord, String> {
|
||||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||||
.append_pair("grant_type", "authorization_code")
|
.append_pair("grant_type", "authorization_code")
|
||||||
.append_pair("client_id", CHATGPT_CLIENT_ID)
|
.append_pair("client_id", CHATGPT_CLIENT_ID)
|
||||||
@@ -516,7 +567,34 @@ async fn exchange_code_for_tokens(
|
|||||||
.append_pair("redirect_uri", redirect_uri)
|
.append_pair("redirect_uri", redirect_uri)
|
||||||
.append_pair("code_verifier", code_verifier)
|
.append_pair("code_verifier", code_verifier)
|
||||||
.finish();
|
.finish();
|
||||||
let response = reqwest::Client::new()
|
let tokens = token_request(body).await.map_err(|error| error.message)?;
|
||||||
|
record_from_tokens(tokens, None, now()).map_err(|error| error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_tokens(previous: &AuthRecord) -> Result<AuthRecord, AuthFailure> {
|
||||||
|
let refresh_token = previous.refresh_token.as_deref().filter(|token| !token.trim().is_empty())
|
||||||
|
.ok_or_else(|| AuthFailure {
|
||||||
|
message: SIGN_IN_REQUIRED.to_owned(),
|
||||||
|
reauthenticate: true,
|
||||||
|
})?;
|
||||||
|
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||||
|
.append_pair("grant_type", "refresh_token")
|
||||||
|
.append_pair("client_id", CHATGPT_CLIENT_ID)
|
||||||
|
.append_pair("refresh_token", refresh_token)
|
||||||
|
.finish();
|
||||||
|
let tokens = token_request(body).await?;
|
||||||
|
record_from_tokens(tokens, Some(previous), now())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn token_request(body: String) -> Result<TokenResponse, AuthFailure> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build().map_err(|_| AuthFailure {
|
||||||
|
message: "Could not create the ChatGPT authorization client.".to_owned(),
|
||||||
|
reauthenticate: false,
|
||||||
|
})?;
|
||||||
|
let response = client
|
||||||
.post(CHATGPT_TOKEN_URL)
|
.post(CHATGPT_TOKEN_URL)
|
||||||
.header(
|
.header(
|
||||||
reqwest::header::CONTENT_TYPE,
|
reqwest::header::CONTENT_TYPE,
|
||||||
@@ -525,67 +603,83 @@ async fn exchange_code_for_tokens(
|
|||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("ChatGPT token exchange request failed: {error}"))?;
|
.map_err(|_| AuthFailure {
|
||||||
|
message: "ChatGPT authorization request failed. Retry the connection.".to_owned(),
|
||||||
let status = response.status();
|
reauthenticate: false,
|
||||||
if !status.is_success() {
|
})?;
|
||||||
let body = response.text().await.unwrap_or_default();
|
let status = response.status().as_u16();
|
||||||
return Err(format!("ChatGPT token exchange failed: {status} {body}"));
|
let mut bytes = Vec::new();
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|_| AuthFailure {
|
||||||
|
message: "Could not read the ChatGPT authorization response.".to_owned(),
|
||||||
|
reauthenticate: (200..300).contains(&status),
|
||||||
|
})?;
|
||||||
|
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
|
||||||
|
return Err(AuthFailure {
|
||||||
|
message: "ChatGPT authorization returned an oversized response.".to_owned(),
|
||||||
|
reauthenticate: (200..300).contains(&status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
let tokens: TokenResponse = response
|
parse_token_response(status, &bytes)
|
||||||
.json()
|
}
|
||||||
.await
|
|
||||||
.map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?;
|
fn parse_token_response(status: u16, bytes: &[u8]) -> Result<TokenResponse, AuthFailure> {
|
||||||
|
if (200..300).contains(&status) {
|
||||||
|
return serde_json::from_slice(bytes).map_err(|_| AuthFailure {
|
||||||
|
message: "ChatGPT returned invalid credentials. Please sign in again.".to_owned(),
|
||||||
|
reauthenticate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body = serde_json::from_slice::<serde_json::Value>(bytes).unwrap_or_default();
|
||||||
|
let code = body.get("error").and_then(|error| {
|
||||||
|
error.as_str().or_else(|| error.get("code").and_then(|code| code.as_str()))
|
||||||
|
});
|
||||||
|
let reauthenticate = matches!(status, 401 | 403)
|
||||||
|
|| (status == 400 && matches!(code,
|
||||||
|
Some("invalid_grant" | "refresh_token_reused" | "refresh_token_expired" | "refresh_token_invalid")
|
||||||
|
));
|
||||||
|
let message = if reauthenticate {
|
||||||
|
format!("ChatGPT authorization expired or was rejected (HTTP {status}). Please sign in again.")
|
||||||
|
} else {
|
||||||
|
format!("ChatGPT authorization failed (HTTP {status}). Retry the connection.")
|
||||||
|
};
|
||||||
|
Err(AuthFailure { message, reauthenticate })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_from_tokens(
|
||||||
|
tokens: TokenResponse,
|
||||||
|
previous: Option<&AuthRecord>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<AuthRecord, AuthFailure> {
|
||||||
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
|
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
|
||||||
tokens.expires_in.map(|expires_in| {
|
tokens.expires_in.and_then(|expires| i64::try_from(expires).ok())
|
||||||
std::time::SystemTime::now()
|
.map(|expires| now.saturating_add(expires))
|
||||||
.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())
|
let account_id = extract_account_id(tokens.id_token.as_deref())
|
||||||
.or_else(|| extract_account_id(Some(&tokens.access_token)));
|
.or_else(|| extract_account_id(Some(&tokens.access_token)))
|
||||||
write_auth_file(&AuthRecord {
|
.or_else(|| previous.and_then(|record| record.account_id.clone()));
|
||||||
|
let record = AuthRecord {
|
||||||
access_token: Some(tokens.access_token),
|
access_token: Some(tokens.access_token),
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token.or_else(|| previous.and_then(|record| record.refresh_token.clone())),
|
||||||
id_token: tokens.id_token,
|
id_token: tokens.id_token.or_else(|| previous.and_then(|record| record.id_token.clone())),
|
||||||
expires_at,
|
expires_at,
|
||||||
account_id,
|
account_id,
|
||||||
})
|
};
|
||||||
|
record.credentials(now).map_err(|_| AuthFailure {
|
||||||
|
message: "ChatGPT returned invalid or expired credentials. Please sign in again.".to_owned(),
|
||||||
|
reauthenticate: true,
|
||||||
|
})?;
|
||||||
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
fn save_auth_record(record: &AuthRecord, ctx: &galaxyui::AppContext) -> Result<(), String> {
|
||||||
let path = auth_file_path().ok_or("Cannot determine auth file path")?;
|
let json = serde_json::to_string(record)
|
||||||
if let Some(parent) = path.parent() {
|
.map_err(|_| "Could not encode ChatGPT credentials.".to_owned())?;
|
||||||
std::fs::create_dir_all(parent)
|
ctx.secure_storage().write_value_with_owner_only_fallback(AUTH_STORAGE_KEY, &json)
|
||||||
.map_err(|e| format!("Failed to create auth directory: {e}"))?;
|
.map_err(|_| "Could not securely save ChatGPT credentials. Please sign in again.".to_owned())
|
||||||
}
|
|
||||||
let json =
|
|
||||||
serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?;
|
|
||||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn auth_file_path() -> Option<std::path::PathBuf> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
std::env::var_os("APPDATA").map(|d| {
|
|
||||||
std::path::PathBuf::from(d)
|
|
||||||
.join("chatgpt")
|
|
||||||
.join("auth.json")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
std::env::var_os("XDG_CONFIG_HOME")
|
|
||||||
.map(std::path::PathBuf::from)
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
|
|
||||||
})
|
|
||||||
.map(|d| d.join("chatgpt").join("auth.json"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_expiration_timestamp(token: &str) -> Option<i64> {
|
fn extract_expiration_timestamp(token: &str) -> Option<i64> {
|
||||||
@@ -633,7 +727,7 @@ struct TokenResponse {
|
|||||||
expires_in: Option<u64>,
|
expires_in: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
#[derive(Clone, Default, serde::Deserialize, serde::Serialize)]
|
||||||
struct AuthRecord {
|
struct AuthRecord {
|
||||||
access_token: Option<String>,
|
access_token: Option<String>,
|
||||||
refresh_token: Option<String>,
|
refresh_token: Option<String>,
|
||||||
@@ -641,3 +735,41 @@ struct AuthRecord {
|
|||||||
expires_at: Option<i64>,
|
expires_at: Option<i64>,
|
||||||
account_id: Option<String>,
|
account_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AuthRecord {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ChatGPTAuthRecord")
|
||||||
|
.field("credentials", &"<redacted>")
|
||||||
|
.field("expires_at", &self.expires_at)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRecord {
|
||||||
|
fn can_refresh(&self) -> bool {
|
||||||
|
self.refresh_token.as_deref().is_some_and(|token| !token.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_context(&self) -> Result<ChatGPTAuthCredentials, String> {
|
||||||
|
let access_token = self.access_token.as_deref().filter(|token| !token.trim().is_empty())
|
||||||
|
.ok_or_else(|| SIGN_IN_REQUIRED.to_owned())?;
|
||||||
|
let account_id = self.account_id.clone().filter(|id| !id.trim().is_empty())
|
||||||
|
.or_else(|| extract_account_id(self.id_token.as_deref()))
|
||||||
|
.or_else(|| extract_account_id(Some(access_token)));
|
||||||
|
Ok(ChatGPTAuthCredentials { access_token: access_token.to_owned(), account_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn credentials(&self, now: i64) -> Result<ChatGPTAuthCredentials, String> {
|
||||||
|
let expiry = self.expires_at.or_else(|| {
|
||||||
|
self.access_token.as_deref().and_then(extract_expiration_timestamp)
|
||||||
|
});
|
||||||
|
if expiry.is_none_or(|expiry| now >= expiry.saturating_sub(60)) {
|
||||||
|
return Err(SIGN_IN_REQUIRED.to_owned());
|
||||||
|
}
|
||||||
|
self.access_context()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "chatgpt_auth_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
use futures::FutureExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn record(access: &str, refresh: &str) -> AuthRecord {
|
||||||
|
AuthRecord {
|
||||||
|
access_token: Some(access.to_owned()),
|
||||||
|
refresh_token: Some(refresh.to_owned()),
|
||||||
|
id_token: None,
|
||||||
|
expires_at: Some(10_000),
|
||||||
|
account_id: Some("test-account".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_requests_share_one_refresh_and_wait_for_persistence() {
|
||||||
|
let mut coordinator = RefreshCoordinator::default();
|
||||||
|
let (first_tx, mut first_rx) = oneshot::channel();
|
||||||
|
let (second_tx, mut second_rx) = oneshot::channel();
|
||||||
|
let generation = coordinator.begin(first_tx).unwrap();
|
||||||
|
assert_eq!(coordinator.begin(second_tx), None);
|
||||||
|
let saved = Cell::new(false);
|
||||||
|
let result = coordinator.complete(generation, Ok(record("rotated-access", "rotated-refresh")), |record| {
|
||||||
|
assert_eq!(first_rx.try_recv().unwrap(), None);
|
||||||
|
assert_eq!(second_rx.try_recv().unwrap(), None);
|
||||||
|
assert_eq!(record.refresh_token.as_deref(), Some("rotated-refresh"));
|
||||||
|
saved.set(true);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(result.unwrap().is_ok());
|
||||||
|
assert!(saved.get());
|
||||||
|
let first = first_rx.now_or_never().unwrap().unwrap().unwrap();
|
||||||
|
let second = second_rx.now_or_never().unwrap().unwrap().unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert_eq!(first.access_token, "rotated-access");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_persistence_never_publishes_rotated_credentials() {
|
||||||
|
let mut coordinator = RefreshCoordinator::default();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let generation = coordinator.begin(tx).unwrap();
|
||||||
|
let result = coordinator.complete(generation, Ok(record("rotated", "new-refresh")), |_| {
|
||||||
|
Err("storage unavailable".to_owned())
|
||||||
|
}).unwrap().unwrap_err();
|
||||||
|
assert!(result.reauthenticate);
|
||||||
|
assert!(rx.now_or_never().unwrap().unwrap().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaced_sign_in_rejects_late_refresh_without_overwriting_new_session() {
|
||||||
|
let mut coordinator = RefreshCoordinator::default();
|
||||||
|
let (old_tx, old_rx) = oneshot::channel();
|
||||||
|
let old = coordinator.begin(old_tx).unwrap();
|
||||||
|
coordinator.invalidate();
|
||||||
|
assert!(old_rx.now_or_never().unwrap().unwrap().is_err());
|
||||||
|
let (new_tx, new_rx) = oneshot::channel();
|
||||||
|
let current = coordinator.begin(new_tx).unwrap();
|
||||||
|
assert_ne!(old, current);
|
||||||
|
assert!(coordinator.complete(old, Ok(record("stale", "stale-refresh")), |_| {
|
||||||
|
panic!("A stale refresh must not write credentials")
|
||||||
|
}).is_none());
|
||||||
|
assert!(coordinator.complete(current, Ok(record("current", "current-refresh")), |_| Ok(()))
|
||||||
|
.unwrap().is_ok());
|
||||||
|
assert_eq!(new_rx.now_or_never().unwrap().unwrap().unwrap().access_token, "current");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconnect_starts_a_new_generation_even_with_a_cached_refresh_token() {
|
||||||
|
let mut model = ChatGPTAuthModel::new_for_test();
|
||||||
|
model.record = Some(record("old-access", "already-used-refresh"));
|
||||||
|
model.state = ChatGPTAuthState::Connected;
|
||||||
|
let first = model.begin_sign_in().unwrap();
|
||||||
|
model.state = ChatGPTAuthState::AwaitingBrowser;
|
||||||
|
assert_eq!(model.begin_sign_in(), None);
|
||||||
|
model.state = ChatGPTAuthState::Failed("refresh_token_reused".to_owned());
|
||||||
|
let replacement = model.begin_sign_in().unwrap();
|
||||||
|
assert!(replacement > first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotated_tokens_replace_previous_tokens_and_omitted_refresh_tokens_are_preserved() {
|
||||||
|
let previous = record("old-access", "old-refresh");
|
||||||
|
for refresh in [None, Some("new-refresh".to_owned())] {
|
||||||
|
let result = record_from_tokens(TokenResponse {
|
||||||
|
access_token: "new-access".to_owned(),
|
||||||
|
refresh_token: refresh.clone(),
|
||||||
|
id_token: None,
|
||||||
|
expires_in: Some(3600),
|
||||||
|
}, Some(&previous), 100).unwrap();
|
||||||
|
assert_eq!(result.access_token.as_deref(), Some("new-access"));
|
||||||
|
assert_eq!(result.refresh_token, refresh.or_else(|| Some("old-refresh".to_owned())));
|
||||||
|
assert_eq!(result.expires_at, Some(3700));
|
||||||
|
assert_eq!(result.account_id.as_deref(), Some("test-account"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_refresh_tokens_require_reauthentication_without_echoing_tokens() {
|
||||||
|
for code in ["refresh_token_reused", "invalid_grant", "refresh_token_expired"] {
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"error": {"code": code, "message": "sensitive-refresh-token"}
|
||||||
|
})).unwrap();
|
||||||
|
let error = parse_token_response(401, &body).err().unwrap();
|
||||||
|
assert!(error.reauthenticate);
|
||||||
|
assert!(error.message.contains("sign in"));
|
||||||
|
assert!(!error.message.contains("sensitive-refresh-token"));
|
||||||
|
}
|
||||||
|
let error = parse_token_response(400, br#"{"error":"invalid_grant"}"#).err().unwrap();
|
||||||
|
assert!(error.reauthenticate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_refresh_failure_preserves_the_session_and_does_not_save_rejected_data() {
|
||||||
|
let error = parse_token_response(503, br#"{"message":"sensitive-data"}"#).err().unwrap();
|
||||||
|
assert!(!error.reauthenticate);
|
||||||
|
assert!(!error.message.contains("sensitive-data"));
|
||||||
|
let mut coordinator = RefreshCoordinator::default();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let generation = coordinator.begin(tx).unwrap();
|
||||||
|
let result = coordinator.complete(generation, Err(error), |_| {
|
||||||
|
panic!("Transient failures must not overwrite stored credentials")
|
||||||
|
}).unwrap().unwrap_err();
|
||||||
|
assert!(!result.reauthenticate);
|
||||||
|
assert!(rx.now_or_never().unwrap().unwrap().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_debug_output_is_redacted_and_expiry_has_a_refresh_margin() {
|
||||||
|
let credentials = ChatGPTAuthCredentials {
|
||||||
|
access_token: "sensitive-access-token".to_owned(),
|
||||||
|
account_id: Some("account".to_owned()),
|
||||||
|
};
|
||||||
|
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
|
||||||
|
let credentials = record("sensitive-access-token", "sensitive-refresh-token");
|
||||||
|
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
|
||||||
|
assert!(!format!("{credentials:?}").contains("sensitive-refresh-token"));
|
||||||
|
assert!(credentials.credentials(100).is_ok());
|
||||||
|
assert!(credentials.credentials(9940).is_err());
|
||||||
|
assert!(AuthRecord::default().credentials(100).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_token_payloads_are_not_saved_or_exposed() {
|
||||||
|
assert!(parse_token_response(200, b"invalid-sensitive-data").is_err());
|
||||||
|
assert!(record_from_tokens(TokenResponse {
|
||||||
|
access_token: String::new(),
|
||||||
|
refresh_token: Some("refresh".to_owned()),
|
||||||
|
id_token: None,
|
||||||
|
expires_in: Some(3600),
|
||||||
|
}, None, 100).is_err());
|
||||||
|
assert!(record_from_tokens(TokenResponse {
|
||||||
|
access_token: "opaque-token-without-expiry".to_owned(),
|
||||||
|
refresh_token: None,
|
||||||
|
id_token: None,
|
||||||
|
expires_in: None,
|
||||||
|
}, None, 100).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_authorization_keeps_pkce_and_state_validation() {
|
||||||
|
let url = build_authorize_url("http://localhost:1455/auth/callback", "challenge", "state").unwrap();
|
||||||
|
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
|
||||||
|
assert_eq!(params.get("code_challenge_method").map(|v| v.as_ref()), Some("S256"));
|
||||||
|
assert_eq!(params.get("state").map(|v| v.as_ref()), Some("state"));
|
||||||
|
let callback = parse_callback_request_line("GET /auth/callback?code=code&state=state HTTP/1.1").unwrap();
|
||||||
|
assert_eq!(callback.state, "state");
|
||||||
|
assert!(parse_callback_request_line("GET /auth/callback?code=code HTTP/1.1").is_err());
|
||||||
|
}
|
||||||
@@ -215,9 +215,8 @@ impl CrosscheckReviewer {
|
|||||||
model: config.model.unwrap_or(model_id),
|
model: config.model.unwrap_or(model_id),
|
||||||
reasoning_effort: config.reasoning_effort,
|
reasoning_effort: config.reasoning_effort,
|
||||||
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
|
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
|
||||||
auth_file: None,
|
|
||||||
},
|
},
|
||||||
);
|
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
|
||||||
runtime
|
runtime
|
||||||
.complete_text(
|
.complete_text(
|
||||||
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
|
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ pub fn init(app: &mut AppContext) {
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
|
app.add_singleton_model(chatgpt_auth::ChatGPTAuthModel::new);
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
|
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
|
||||||
blocklist::keyboard_navigable_buttons::init(app);
|
blocklist::keyboard_navigable_buttons::init(app);
|
||||||
|
|||||||
@@ -314,9 +314,8 @@ pub(crate) async fn provider_runtime_for_request(
|
|||||||
model,
|
model,
|
||||||
reasoning_effort: config.reasoning_effort,
|
reasoning_effort: config.reasoning_effort,
|
||||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||||
auth_file: None,
|
|
||||||
},
|
},
|
||||||
)),
|
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver())),
|
||||||
OpenAIProviderKind::Anthropic => {
|
OpenAIProviderKind::Anthropic => {
|
||||||
let credential = config.api_key.unwrap_or_default();
|
let credential = config.api_key.unwrap_or_default();
|
||||||
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
|
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ pub enum ProviderSetupViewAction {
|
|||||||
ToggleModel(usize),
|
ToggleModel(usize),
|
||||||
CycleModelCapability(usize, CapabilityKey),
|
CycleModelCapability(usize, CapabilityKey),
|
||||||
ConnectChatGPT,
|
ConnectChatGPT,
|
||||||
|
CancelChatGPTSignIn,
|
||||||
ConnectClaude,
|
ConnectClaude,
|
||||||
SignOutClaude,
|
SignOutClaude,
|
||||||
CancelClaudeSignIn,
|
CancelClaudeSignIn,
|
||||||
@@ -1590,10 +1591,10 @@ impl ProviderSetupView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(
|
let pending = matches!(state,
|
||||||
state,
|
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||||
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_)
|
);
|
||||||
) {
|
{
|
||||||
children.push(
|
children.push(
|
||||||
appearance
|
appearance
|
||||||
.ui_builder()
|
.ui_builder()
|
||||||
@@ -1601,10 +1602,21 @@ impl ProviderSetupView {
|
|||||||
ButtonVariant::Secondary,
|
ButtonVariant::Secondary,
|
||||||
self.chatgpt_connect_mouse_state.clone(),
|
self.chatgpt_connect_mouse_state.clone(),
|
||||||
)
|
)
|
||||||
.with_text_label("Connect ChatGPT".to_owned())
|
.with_text_label(if pending {
|
||||||
|
"Cancel sign-in"
|
||||||
|
} else if matches!(state, ChatGPTAuthState::Connected) {
|
||||||
|
"Reconnect ChatGPT"
|
||||||
|
} else {
|
||||||
|
"Connect ChatGPT"
|
||||||
|
}.to_owned())
|
||||||
.build()
|
.build()
|
||||||
.on_click(|ctx, _, _| {
|
.on_click(move |ctx, _, _| {
|
||||||
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT);
|
let action = if pending {
|
||||||
|
ProviderSetupViewAction::CancelChatGPTSignIn
|
||||||
|
} else {
|
||||||
|
ProviderSetupViewAction::ConnectChatGPT
|
||||||
|
};
|
||||||
|
ctx.dispatch_typed_action(action);
|
||||||
})
|
})
|
||||||
.finish(),
|
.finish(),
|
||||||
);
|
);
|
||||||
@@ -1861,7 +1873,7 @@ impl ProviderSetupView {
|
|||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
|
fn render_discovery(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn galaxyui::Element> {
|
||||||
let (message, color) = match &self.discovery_state {
|
let (message, color) = match &self.discovery_state {
|
||||||
DiscoveryState::Idle | DiscoveryState::Loading => (
|
DiscoveryState::Idle | DiscoveryState::Loading => (
|
||||||
if self.provider_type == ProviderSetupProviderType::Acp
|
if self.provider_type == ProviderSetupProviderType::Acp
|
||||||
@@ -1875,7 +1887,7 @@ impl ProviderSetupView {
|
|||||||
),
|
),
|
||||||
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
|
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
|
||||||
};
|
};
|
||||||
Flex::column()
|
let mut column = Flex::column()
|
||||||
.with_spacing(12.)
|
.with_spacing(12.)
|
||||||
.with_child(
|
.with_child(
|
||||||
Text::new(
|
Text::new(
|
||||||
@@ -1913,8 +1925,13 @@ impl ProviderSetupView {
|
|||||||
.with_color(color)
|
.with_color(color)
|
||||||
.soft_wrap(true)
|
.soft_wrap(true)
|
||||||
.finish()
|
.finish()
|
||||||
})
|
});
|
||||||
.finish()
|
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription
|
||||||
|
&& matches!(self.discovery_state, DiscoveryState::Failed(_))
|
||||||
|
{
|
||||||
|
column = column.with_child(self.render_chatgpt_auth(appearance, app));
|
||||||
|
}
|
||||||
|
column.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_model_table(
|
fn render_model_table(
|
||||||
@@ -2468,7 +2485,7 @@ impl View for ProviderSetupView {
|
|||||||
let content = match self.step {
|
let content = match self.step {
|
||||||
ProviderSetupStep::ProviderType => self.render_provider_type(appearance),
|
ProviderSetupStep::ProviderType => self.render_provider_type(appearance),
|
||||||
ProviderSetupStep::Configure => self.render_configure(appearance, app),
|
ProviderSetupStep::Configure => self.render_configure(appearance, app),
|
||||||
ProviderSetupStep::Discover => self.render_discovery(appearance),
|
ProviderSetupStep::Discover => self.render_discovery(appearance, app),
|
||||||
ProviderSetupStep::Models => self.render_models(appearance),
|
ProviderSetupStep::Models => self.render_models(appearance),
|
||||||
};
|
};
|
||||||
Flex::column()
|
Flex::column()
|
||||||
@@ -2488,6 +2505,10 @@ impl TypedActionView for ProviderSetupView {
|
|||||||
match action {
|
match action {
|
||||||
ProviderSetupViewAction::SelectProvider(kind) => {
|
ProviderSetupViewAction::SelectProvider(kind) => {
|
||||||
if self.provider_type != *kind {
|
if self.provider_type != *kind {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||||
|
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||||
|
}
|
||||||
self.cancel_claude_signin();
|
self.cancel_claude_signin();
|
||||||
self.draft_api_key = None;
|
self.draft_api_key = None;
|
||||||
self.claude_credentials_available = false;
|
self.claude_credentials_available = false;
|
||||||
@@ -2588,6 +2609,10 @@ impl TypedActionView for ProviderSetupView {
|
|||||||
ProviderSetupViewAction::Cancel => {
|
ProviderSetupViewAction::Cancel => {
|
||||||
self.cancel_acp_setup();
|
self.cancel_acp_setup();
|
||||||
self.cancel_claude_signin();
|
self.cancel_claude_signin();
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||||
|
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||||
|
}
|
||||||
self.discovery_state = DiscoveryState::Idle;
|
self.discovery_state = DiscoveryState::Idle;
|
||||||
ctx.emit(ProviderSetupViewEvent::Close);
|
ctx.emit(ProviderSetupViewEvent::Close);
|
||||||
}
|
}
|
||||||
@@ -2633,6 +2658,10 @@ 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::CancelChatGPTSignIn => {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||||
|
}
|
||||||
ProviderSetupViewAction::ConnectClaude => {
|
ProviderSetupViewAction::ConnectClaude => {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
self.connect_claude(ctx);
|
self.connect_claude(ctx);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use galaxy_agent_core::{
|
use galaxy_agent_core::{
|
||||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
|
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
|
||||||
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
|
|||||||
TurnRequest, turn_control,
|
TurnRequest, turn_control,
|
||||||
};
|
};
|
||||||
use rig_core::client::CompletionClient;
|
use rig_core::client::CompletionClient;
|
||||||
use rig_core::providers::chatgpt;
|
use rig_core::providers::chatgpt::{self, ChatGPTAuth};
|
||||||
|
|
||||||
use crate::request::build_completion_request;
|
use crate::request::build_completion_request;
|
||||||
use crate::stream::start_model_turn;
|
use crate::stream::start_model_turn;
|
||||||
|
|
||||||
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
/// Rig to fall back to its interactive device-code flow.
|
pub struct ChatGPTSubscriptionCredentials {
|
||||||
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
|
pub access_token: String,
|
||||||
let client = chatgpt::Client::builder()
|
pub account_id: Option<String>,
|
||||||
.oauth()
|
|
||||||
.allow_device_flow(false)
|
|
||||||
.originator("galaxy")
|
|
||||||
.build()
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
client.authorize().await.map_err(|error| error.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ChatGPTSubscriptionCredentials {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ChatGPTSubscriptionCredentials")
|
||||||
|
.field("access_token", &"<redacted>")
|
||||||
|
.field("account_id", &self.account_id.as_ref().map(|_| "<redacted>"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub type ChatGPTCredentialResolver = Arc<
|
||||||
|
dyn Fn() -> BoxFuture<'static, Result<ChatGPTSubscriptionCredentials, String>> + Send + Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct ChatGPTSubscriptionRuntimeConfig {
|
pub struct ChatGPTSubscriptionRuntimeConfig {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub reasoning_effort: Option<String>,
|
pub reasoning_effort: Option<String>,
|
||||||
pub max_output_tokens: Option<u64>,
|
pub max_output_tokens: Option<u64>,
|
||||||
pub auth_file: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct ChatGPTSubscriptionRuntime {
|
pub struct ChatGPTSubscriptionRuntime {
|
||||||
config: ChatGPTSubscriptionRuntimeConfig,
|
config: ChatGPTSubscriptionRuntimeConfig,
|
||||||
descriptor: RuntimeDescriptor,
|
descriptor: RuntimeDescriptor,
|
||||||
|
credential_resolver: Option<ChatGPTCredentialResolver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ChatGPTSubscriptionRuntime {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ChatGPTSubscriptionRuntime")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.field("has_credential_resolver", &self.credential_resolver.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
|
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
|
||||||
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
|
|||||||
kind: RuntimeKind::Provider,
|
kind: RuntimeKind::Provider,
|
||||||
capabilities: RuntimeCapabilities::provider(),
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
};
|
};
|
||||||
Self { config, descriptor }
|
Self { config, descriptor, credential_resolver: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_credential_resolver(mut self, resolver: ChatGPTCredentialResolver) -> Self {
|
||||||
|
self.credential_resolver = Some(resolver);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_auth(&self) -> Result<ChatGPTAuth, AgentError> {
|
||||||
|
let resolver = self.credential_resolver.as_ref().ok_or_else(|| {
|
||||||
|
AgentError::new(AgentErrorKind::Authentication, "ChatGPT sign-in required. Reconnect ChatGPT in Providers.")
|
||||||
|
})?;
|
||||||
|
let credentials = resolver().await
|
||||||
|
.map_err(|message| AgentError::new(AgentErrorKind::Authentication, message))?;
|
||||||
|
Ok(ChatGPTAuth::AccessToken {
|
||||||
|
access_token: credentials.access_token,
|
||||||
|
account_id: credentials.account_id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes a text-only request for callers such as crosscheck reviewers.
|
/// Completes a text-only request for callers such as crosscheck reviewers.
|
||||||
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
|||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
control: TurnControl,
|
control: TurnControl,
|
||||||
) -> Result<AgentEventStream, AgentError> {
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
let mut builder = chatgpt::Client::builder()
|
let auth = self.resolve_auth().await?;
|
||||||
.oauth()
|
let client = chatgpt::Client::builder()
|
||||||
|
.api_key(auth)
|
||||||
.allow_device_flow(false)
|
.allow_device_flow(false)
|
||||||
.originator("galaxy");
|
.originator("galaxy")
|
||||||
if let Some(auth_file) = &self.config.auth_file {
|
|
||||||
builder = builder.auth_file(auth_file);
|
|
||||||
}
|
|
||||||
let client = builder
|
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||||
let model = client.completion_model(&self.config.model);
|
let model = client.completion_model(&self.config.model);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
|
|||||||
model: "gpt-5.3-codex".to_string(),
|
model: "gpt-5.3-codex".to_string(),
|
||||||
reasoning_effort: Some("high".to_string()),
|
reasoning_effort: Some("high".to_string()),
|
||||||
max_output_tokens: None,
|
max_output_tokens: None,
|
||||||
auth_file: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -25,6 +24,43 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_resolves_current_credentials_without_rig_oauth() {
|
||||||
|
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
model: "test-model".to_owned(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
max_output_tokens: None,
|
||||||
|
}).with_credential_resolver(std::sync::Arc::new(|| {
|
||||||
|
Box::pin(async {
|
||||||
|
Ok(ChatGPTSubscriptionCredentials {
|
||||||
|
access_token: "resolved-token".to_owned(),
|
||||||
|
account_id: Some("resolved-account".to_owned()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
match runtime.resolve_auth().await.unwrap() {
|
||||||
|
ChatGPTAuth::AccessToken { access_token, account_id } => {
|
||||||
|
assert_eq!(access_token, "resolved-token");
|
||||||
|
assert_eq!(account_id.as_deref(), Some("resolved-account"));
|
||||||
|
}
|
||||||
|
ChatGPTAuth::OAuth => panic!("The runtime must not own OAuth refresh"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_or_failed_resolver_requires_sign_in_without_device_flow() {
|
||||||
|
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
model: "test-model".to_owned(),
|
||||||
|
reasoning_effort: None,
|
||||||
|
max_output_tokens: None,
|
||||||
|
});
|
||||||
|
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
|
||||||
|
let runtime = runtime.with_credential_resolver(std::sync::Arc::new(|| {
|
||||||
|
Box::pin(async { Err("Sign in again".to_owned()) })
|
||||||
|
}));
|
||||||
|
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reasoning_effort_is_encoded_for_the_responses_request() {
|
fn reasoning_effort_is_encoded_for_the_responses_request() {
|
||||||
let request = TurnRequest::new(
|
let request = TurnRequest::new(
|
||||||
|
|||||||
Reference in New Issue
Block a user