From 7ff127d530671242fae8a100573511c8afa38e83 Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:15:09 +0800 Subject: [PATCH 1/2] Share one managed refresh and confirm rejection with the provider Switch, manual quota refresh, and Wake each ran their own isolated token refresh and classified its failure differently, so the same dead saved sign-in was "needs sign-in" in Wake, "temporarily unavailable" in Switch, and "try again later" for quota. Codex does not report a refresh outcome in a supported form: 0.144.5 answers account/read from its cached account after a failed refresh, and 0.159.2 rejects the read without a typed reason. Use one shared refresh that never reads the outcome from that reply. It keeps a rotated same-identity credential immediately, then the caller repeats its own provider request once. Only a second 401 or 403 marks the account as needing sign-in; an App Server that cannot start or answer, and every non-authentication provider failure, stay "unavailable". Co-Authored-By: Claude Opus 5.5 --- docs/security.md | 3 +- docs/testing.md | 4 + docs/workflows.md | 41 +++- src-tauri/src/accounts.rs | 40 +++- src-tauri/src/app_server.rs | 41 ++++ src-tauri/src/quota.rs | 128 +++++++---- src-tauri/src/switching.rs | 416 ++++++++++++++++++++++++++---------- src-tauri/src/wake.rs | 73 +++++-- 8 files changed, 553 insertions(+), 193 deletions(-) diff --git a/docs/security.md b/docs/security.md index a5b6b0a..e4cd75c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -127,7 +127,8 @@ Before replacing live credentials: external-process check, but rate limits, transport, TLS, timeout, 5xx, and parse failures must not enter that fallback; 7. any refreshed credential must still match the saved identity before it is - persisted; + persisted, and must pass the read-only account check before it can become + the live credential; 8. pending recovery intent must be durably stored; 9. the external process state and live credential fingerprint must still match the preflight observations. diff --git a/docs/testing.md b/docs/testing.md index 2ca237e..42cc9c3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -150,6 +150,10 @@ as applicable: - one 401/403 switch fallback through an isolated managed refresh, with all rate-limit, network, TLS, timeout, 5xx, and malformed-response failures refusing that fallback; +- the shared managed refresh saving a rotated same-identity credential before + the repeated request, never saving another identity, reporting sign-in only + when ChatGPT rejects the credential again, and keeping an unavailable Codex + runtime or provider distinct from a rejected sign-in; - API-key switching performing local structure and stable-identity checks with no provider request; - target identity mismatch and live-fingerprint races preventing mutation; diff --git a/docs/workflows.md b/docs/workflows.md index 3109eeb..3fa5435 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -178,8 +178,8 @@ The visible interaction is one **Switch** action. Rust owns the full transaction credential or its stable identity claim; API-key identity is checked locally without a network request; 6. only when that ChatGPT check returns 401 or 403, check the external process - state again and allow one isolated managed refresh; identity-check the - refreshed complete document before saving it; + state again, allow one [managed refresh](#managed-refresh), and repeat the + account check once with the resulting credential; 7. persist pending-switch metadata and protected rollback auth; 8. check the external process state and live credential fingerprint again; 9. atomically replace the live credential; @@ -220,6 +220,31 @@ updates GSwitch's account library; it never edits live `auth.json` and is allowe while Codex runs. If another GSwitch operation owns the lock, the confirmation stays open and asks the user to retry after that operation finishes. +## Managed refresh + +Switch, a manual quota refresh, and Wake share one isolated refresh for a saved +ChatGPT sign-in that ChatGPT rejected with 401 or 403 and that no running Codex +process owns. GSwitch copies the saved credential into a GSwitch-owned profile +and asks the official App Server for one token refresh. + +Codex does not report that refresh's outcome in a supported form. The minimum +supported version answers from its cached account after a failed refresh, and +current versions reject the read without a typed reason. GSwitch therefore +never takes the outcome from the App Server reply. It rereads the profile's +complete credential and requires the saved identity. A rotated document is +committed immediately, or to protected recovery if that commit fails, so a +consumed refresh token never stays saved; a document for another identity is +never saved. + +The caller then repeats its own provider request once with that credential. A +second 401 or 403 is the confirmed rejection, and only then is the account +marked as needing sign-in. A Codex runtime that cannot start or answer, an +unreadable profile, and every non-authentication provider failure leave the +sign-in unjudged and are reported as unavailable. A refresh that fails only +transiently at the identity provider while ChatGPT stays reachable cannot be +told apart from a rejected one and is also reported as needing sign-in; a +later successful refresh or a new sign-in clears it. + ## Quota Quota is read from ChatGPT's current read-only usage endpoint with the live @@ -239,10 +264,9 @@ never treats a cached `account/read` result as proof that a credential can reach the provider. If the read-only endpoint rejects an inactive saved credential with an -authentication response, GSwitch may fall back to the existing isolated App -Server refresh path, verifies the returned document still belongs to the saved -identity, and atomically stores it with the quota snapshot. A successful -read-only result stores only the quota projection. A snapshot is fresh for five +authentication response, a manual refresh may use the +[managed refresh](#managed-refresh) and then repeats the read-only request +once. A successful read-only result stores only the quota projection. A snapshot is fresh for five minutes and then visibly stale. API-key accounts show quota as not applicable. Quota is operational account state, not usage analytics. @@ -316,8 +340,9 @@ snapshot, and an unidentifiable active process uses the saved snapshot without a managed refresh. GSwitch never writes live `auth.json`. An authentication failure for a definitely inactive account may use one -isolated official Codex App Server refresh. That profile is identity-checked -before its refreshed credential is stored. An active or uncertain account never +[managed refresh](#managed-refresh), after which the Wake request is sent once +more. Only a second authentication failure reports Needs sign-in; a refresh +that could not run reports Failed. An active or uncertain account never enters this fallback. GSwitch reads the live token once immediately before the request. It does not retry after uncertain delivery. diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 86af26c..1d4cbe3 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -1082,14 +1082,39 @@ impl AppState { Ok(()) } - /// Commits a verified provider projection and, only when authentication - /// required it, a refreshed credential in one account-store replacement. + /// Replaces a saved credential with the same-identity document Codex + /// refreshed in an isolated profile. The caller has verified the identity; + /// account metadata and quota wait for the next provider read. + pub fn update_refreshed_credential_under_operation( + &self, + _operation: &OperationGuard<'_>, + id: &str, + credential: Value, + ) -> Result<(), String> { + let mut store = self + .store + .lock() + .map_err(|_| "Account store lock is unavailable".to_string())?; + let index = store + .accounts + .iter() + .position(|account| account.id == id) + .ok_or_else(|| "The selected account is no longer saved".to_string())?; + let mut candidate = store.clone(); + candidate.accounts[index].needs_apply |= candidate.accounts[index].credential != credential; + candidate.accounts[index].sign_in_required = false; + candidate.accounts[index].credential = credential; + self.persist_candidate(&store, &mut candidate)?; + *store = candidate; + Ok(()) + } + + /// Commits the provider projection verified for a switch target. pub fn update_switch_validation_under_operation( &self, _operation: &OperationGuard<'_>, id: &str, metadata: &AccountMetadata, - credential: Option, ) -> Result<(), String> { let mut store = self .store @@ -1101,8 +1126,7 @@ impl AppState { .position(|account| account.id == id) .ok_or_else(|| "The selected account is no longer saved".to_string())?; let saved = &store.accounts[index]; - if credential.is_none() - && !saved.sign_in_required + if !saved.sign_in_required && saved.email == metadata.email && saved.plan_type == metadata.plan_type && metadata @@ -1127,12 +1151,6 @@ impl AppState { if metadata.account_structure.is_some() { account.account_structure = metadata.account_structure.clone(); } - if let Some(credential) = credential { - account.needs_apply |= account.credential != credential; - account.credential = credential; - account.quota = None; - account.reset_credits = None; - } self.persist_candidate(&store, &mut candidate)?; *store = candidate; Ok(()) diff --git a/src-tauri/src/app_server.rs b/src-tauri/src/app_server.rs index adca707..232b0ab 100644 --- a/src-tauri/src/app_server.rs +++ b/src-tauri/src/app_server.rs @@ -185,6 +185,21 @@ impl AppServer { ) } + /// Asks Codex for one official token refresh of this profile's sign-in. + /// Codex does not report the outcome in a supported form: 0.144 answers + /// with the cached account after a failed refresh, and 0.159 rejects the + /// read without a typed reason. The caller must check the profile's + /// credential with the provider; only an App Server that could not answer + /// is an error here. + pub fn account_refresh(&mut self, id: i64) -> Result<(), String> { + refresh_attempt_completed(self.call_protocol( + id, + "account/read", + json!({"refreshToken": true}), + REQUEST_TIMEOUT, + )) + } + pub fn account_login_cancel(&mut self, id: i64, login_id: &str) -> Result { self.call( id, @@ -569,6 +584,13 @@ fn should_retry_rate_limits_with_empty_object(error: &CallError) -> bool { ) } +fn refresh_attempt_completed(result: Result) -> Result<(), String> { + match result { + Ok(_) | Err(CallError::Rejected { .. }) => Ok(()), + Err(error) => Err(error.sanitized()), + } +} + #[cfg(test)] fn response_result(message: Value) -> Result { response_result_protocol(message).map_err(CallError::sanitized) @@ -807,6 +829,25 @@ mod tests { )); } + #[test] + fn a_rejected_refresh_read_still_counts_as_an_attempt() { + assert_eq!( + refresh_attempt_completed(Ok(json!({"account": null}))), + Ok(()) + ); + assert_eq!( + refresh_attempt_completed(Err(CallError::Rejected { code: Some(-32603) })), + Ok(()) + ); + assert_eq!( + refresh_attempt_completed(Err(CallError::Transport( + "Codex App Server exited unexpectedly".to_string() + ))), + Err("Codex App Server exited unexpectedly".to_string()) + ); + assert!(refresh_attempt_completed(Err(CallError::MissingResult)).is_err()); + } + #[test] fn waiting_for_a_message_times_out() { let (_sender, receiver) = mpsc::channel(); diff --git a/src-tauri/src/quota.rs b/src-tauri/src/quota.rs index 9909dc6..7b528ae 100644 --- a/src-tauri/src/quota.rs +++ b/src-tauri/src/quota.rs @@ -70,9 +70,10 @@ pub fn cached_quota(state: &AppState, account_id: &str) -> Result Result { let operation = state.acquire_operation()?; let account = state.account_by_id_under_operation(&operation, account_id)?; @@ -96,7 +97,10 @@ pub fn refresh_quota(state: &AppState, account_id: &str) -> Result Ok(view), Err(ReadOnlyRefreshFailure::Provider(error)) if error.can_fallback_to_managed_refresh() => { - refresh_via_managed_profile(state, &operation, &account, &identity) + let refreshed = refresh_saved_sign_in(state, &operation, &account, &identity) + .map_err(ManagedRefreshFailure::message)?; + refresh_read_only(state, &operation, &account, &identity, &refreshed) + .map_err(ReadOnlyRefreshFailure::message) } Err(error) => Err(error.message()), } @@ -285,58 +289,94 @@ impl ReadOnlyRefreshFailure { } } -fn refresh_via_managed_profile( +/// Why one isolated official refresh of an inactive saved sign-in produced no +/// credential to retry with. None of these says ChatGPT rejected the sign-in. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ManagedRefreshFailure { + /// Codex could not run the refresh, or its result could not be read. + Unavailable(String), + /// Codex returned a credential for another account; nothing was saved. + IdentityChanged, + /// Codex rotated the credential but GSwitch could not commit it. + NotSaved { recovery_retained: bool }, +} + +impl ManagedRefreshFailure { + pub(crate) fn message(self) -> String { + match self { + Self::Unavailable(message) => message, + Self::IdentityChanged => { + "Codex did not confirm the refreshed account identity".to_string() + } + Self::NotSaved { + recovery_retained: true, + } => "GSwitch could not save refreshed credentials. A protected recovery copy was retained." + .to_string(), + Self::NotSaved { + recovery_retained: false, + } => "GSwitch could not save refreshed credentials or write protected recovery. The temporary profile was removed." + .to_string(), + } + } +} + +/// Runs one official token refresh for a saved ChatGPT sign-in in an isolated +/// profile and returns the credential to retry with. Callers must first +/// establish that no external Codex process owns this identity. +/// +/// Codex does not report whether the refresh was rejected, so this never +/// decides that the account needs sign-in. The caller repeats its own provider +/// request with the returned credential; an authentication failure there is +/// the confirmed rejection. +pub(crate) fn refresh_saved_sign_in( state: &AppState, operation: &OperationGuard<'_>, account: &StoredAccount, identity: &AccountIdentity, -) -> Result { - let refreshed = refresh_via_managed_profile_for_wake(state, operation, account, identity)?; - Ok(view_from_snapshot( - &account.id, - refreshed.snapshot, - now_unix_ms(), - )) -} - -pub(crate) struct ManagedQuotaRefresh { - pub(crate) credential: Value, - pub(crate) snapshot: QuotaSnapshot, +) -> Result { + let unavailable = ManagedRefreshFailure::Unavailable; + let temporary = TempCodexHome::create(&state.isolated_profile_root().map_err(unavailable)?) + .map_err(unavailable)?; + temporary + .write_auth(&account.credential) + .map_err(unavailable)?; + let mut server = AppServer::start(&temporary.path).map_err(unavailable)?; + let attempt = server.account_refresh(1); + + // Codex may have rotated the token chain even when its reply never + // arrived, so keep a newer credential before reporting that failure. + let refreshed = temporary.read_auth().map_err(unavailable)?; + let credential = keep_refreshed_credential(state, operation, account, identity, refreshed)?; + attempt.map_err(unavailable)?; + Ok(credential) } -/// Refreshes an inactive credential only through an isolated profile. Callers -/// must first establish that no external Codex process owns this identity. -pub(crate) fn refresh_via_managed_profile_for_wake( +/// Commits a credential Codex rotated for the same identity before anything +/// else can fail, so a consumed refresh token never stays saved. +pub(crate) fn keep_refreshed_credential( state: &AppState, operation: &OperationGuard<'_>, account: &StoredAccount, identity: &AccountIdentity, -) -> Result { - let temporary = TempCodexHome::create(&state.isolated_profile_root()?)?; - temporary.write_auth(&account.credential)?; - let mut server = AppServer::start(&temporary.path)?; - let result = server.rate_limits_read(1)?; - let refreshed_credential = temporary.read_auth()?; - - if document_kind(&refreshed_credential)? != AccountKind::ChatGpt - || derive_identity(&AccountKind::ChatGpt, &refreshed_credential)? != *identity + refreshed: Value, +) -> Result { + let same_identity = document_kind(&refreshed).is_ok_and(|kind| kind == AccountKind::ChatGpt) + && derive_identity(&AccountKind::ChatGpt, &refreshed) + .is_ok_and(|derived| &derived == identity); + if !same_identity { + return Err(ManagedRefreshFailure::IdentityChanged); + } + if refreshed == account.credential + || state + .update_refreshed_credential_under_operation(operation, &account.id, refreshed.clone()) + .is_ok() { - return Err("Codex did not confirm the quota account identity".to_string()); + return Ok(refreshed); } - - let normalized = normalize_rate_limits_data(&result, now_unix_ms()); - let snapshot = normalized.snapshot; - persist_refreshed_credential_and_quota( - state, - operation, - &account.id, - &refreshed_credential, - snapshot.clone(), - normalized.reset_credits, - )?; - Ok(ManagedQuotaRefresh { - credential: refreshed_credential, - snapshot, + Err(ManagedRefreshFailure::NotSaved { + recovery_retained: state + .record_pending_credential(operation, &refreshed) + .is_ok(), }) } diff --git a/src-tauri/src/switching.rs b/src-tauri/src/switching.rs index 62c18c1..0510d95 100644 --- a/src-tauri/src/switching.rs +++ b/src-tauri/src/switching.rs @@ -6,6 +6,7 @@ use crate::{ chatgpt::{self, ChatGptClient, RequestFailureKind}, codex, identity::{derive_identity, document_fingerprint, document_kind}, + quota::{refresh_saved_sign_in, ManagedRefreshFailure}, runtime, types::{ AccountIdentity, AccountKind, AccountView, CredentialStoreMode, LiveAccountStatus, @@ -550,15 +551,10 @@ fn validate_target_snapshot( runtime::ensure_no_external_codex(&[]) .map_err(|_| switch_failure(SwitchFailureCode::CodexOpen)) }, - || managed_refresh_target(state, target), + || refresh_saved_sign_in(state, operation, target, target_identity), ) } -struct ManagedRefresh { - credential: Value, - metadata: AccountMetadata, -} - fn validate_chatgpt_snapshot_with( state: &AppState, operation: &OperationGuard<'_>, @@ -570,92 +566,75 @@ fn validate_chatgpt_snapshot_with( ) -> Result where P: FnOnce() -> Result<(), SwitchFailure>, - F: FnOnce() -> Result, + F: FnOnce() -> Result, { - match client.account_check(&target.credential) { - Ok(response) => { - chatgpt::validate_response_identity(&target.credential, target_identity, &response) - .map_err(|_| switch_failure(SwitchFailureCode::TargetWorkspaceMismatch))?; - let projection = - chatgpt::normalize_account_metadata(&target.credential, target_identity, &response) - .map_err(|_| switch_failure(SwitchFailureCode::TargetWorkspaceMismatch))?; - let metadata = AccountMetadata { - kind: AccountKind::ChatGpt, - email: projection.email, - plan_type: projection.plan_type, - workspace_name: projection.workspace_name, - account_structure: projection.account_structure, - }; - persist_switch_validation(state, operation, target, &metadata, None)?; - Ok(target.credential.clone()) - } - Err(error) if error.kind == RequestFailureKind::Authentication => { - refresh_precondition()?; - let refreshed = managed_refresh() - .map_err(|_| switch_failure(SwitchFailureCode::TargetCheckUnavailable))?; - ensure_metadata_kind(&refreshed.metadata, &AccountKind::ChatGpt) - .map_err(|_| switch_failure(SwitchFailureCode::AccountNeedsSignIn))?; - ensure_credential_identity( - &refreshed.credential, - &AccountKind::ChatGpt, - target_identity, - "Codex refreshed a different account", - ) - .map_err(|_| switch_failure(SwitchFailureCode::AccountNeedsSignIn))?; - persist_switch_validation( - state, - operation, - target, - &refreshed.metadata, - Some(refreshed.credential.clone()), - )?; - Ok(refreshed.credential) - } - Err(_) => Err(switch_failure(SwitchFailureCode::TargetCheckUnavailable)), + let check = |credential: &Value| { + target_snapshot_accepted( + state, + operation, + target, + target_identity, + client, + credential, + ) + }; + if check(&target.credential)? { + return Ok(target.credential.clone()); } -} -fn managed_refresh_target( - state: &AppState, - target: &StoredAccount, -) -> Result { - let profile = TempCodexHome::create(&state.isolated_profile_root()?)?; - profile.write_auth(&target.credential)?; - let mut server = AppServer::start(&profile.path)?; - let metadata = account_metadata(&server.account_read(1, true)?)?; - let credential = profile.read_auth()?; - Ok(ManagedRefresh { - credential, - metadata, - }) + refresh_precondition()?; + let refreshed = managed_refresh().map_err(|failure| { + switch_failure(match failure { + ManagedRefreshFailure::Unavailable(_) => SwitchFailureCode::TargetCheckUnavailable, + ManagedRefreshFailure::NotSaved { + recovery_retained: true, + } => SwitchFailureCode::RecoveryRequired, + ManagedRefreshFailure::IdentityChanged + | ManagedRefreshFailure::NotSaved { + recovery_retained: false, + } => SwitchFailureCode::AccountNeedsSignIn, + }) + })?; + // Codex does not say whether its refresh was rejected. ChatGPT rejecting + // the credential again after that attempt is the confirmed failure. + if check(&refreshed)? { + Ok(refreshed) + } else { + Err(switch_failure(SwitchFailureCode::AccountNeedsSignIn)) + } } -fn persist_switch_validation( +/// Runs one read-only account check for a target credential and commits the +/// verified provider metadata. `Ok(false)` means ChatGPT rejected the +/// credential itself; every other provider failure is unavailability. +fn target_snapshot_accepted( state: &AppState, operation: &OperationGuard<'_>, target: &StoredAccount, - metadata: &AccountMetadata, - credential: Option, -) -> Result<(), SwitchFailure> { - if state - .update_switch_validation_under_operation( - operation, - &target.id, - metadata, - credential.clone(), - ) - .is_ok() - { - return Ok(()); - } - - let Some(credential) = credential else { - return Err(switch_failure(SwitchFailureCode::LocalVerificationFailed)); + target_identity: &AccountIdentity, + client: &ChatGptClient, + credential: &Value, +) -> Result { + let response = match client.account_check(credential) { + Ok(response) => response, + Err(error) if error.kind == RequestFailureKind::Authentication => return Ok(false), + Err(_) => return Err(switch_failure(SwitchFailureCode::TargetCheckUnavailable)), }; - match state.record_pending_credential(operation, &credential) { - Ok(()) => Err(switch_failure(SwitchFailureCode::RecoveryRequired)), - Err(_) => Err(switch_failure(SwitchFailureCode::AccountNeedsSignIn)), - } + chatgpt::validate_response_identity(credential, target_identity, &response) + .map_err(|_| switch_failure(SwitchFailureCode::TargetWorkspaceMismatch))?; + let projection = chatgpt::normalize_account_metadata(credential, target_identity, &response) + .map_err(|_| switch_failure(SwitchFailureCode::TargetWorkspaceMismatch))?; + let metadata = AccountMetadata { + kind: AccountKind::ChatGpt, + email: projection.email, + plan_type: projection.plan_type, + workspace_name: projection.workspace_name, + account_structure: projection.account_structure, + }; + state + .update_switch_validation_under_operation(operation, &target.id, &metadata) + .map_err(|_| switch_failure(SwitchFailureCode::LocalVerificationFailed))?; + Ok(true) } fn confirm_already_active( @@ -861,6 +840,7 @@ fn config_store_value(config: &Value) -> Option<&str> { #[cfg(test)] mod tests { use super::*; + use crate::quota::keep_refreshed_credential; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use std::{ fs, @@ -1231,10 +1211,26 @@ mod tests { let _ = fs::remove_dir_all(root); } + const ACCEPTED_ACCOUNT_CHECK: &str = r#"{"accounts":[{"id":"workspace","name":"Updated","structure":"workspace","plan_type":"plus"}]}"#; + + fn account_check_sequence( + steps: Vec<(u16, &'static str)>, + ) -> (String, std::thread::JoinHandle>) { + // No step rotates a live credential here, so the Codex home is unused. + current_account_check_sequence( + std::path::PathBuf::new(), + steps + .into_iter() + .map(|(status, body)| (status, body, None)) + .collect(), + ) + } + #[test] fn authentication_failure_allows_exactly_one_identity_checked_refresh() { let (state, target, root) = state_with_chatgpt_target(); - let (base_url, server) = account_check_server(401, r#"{}"#); + let (base_url, server) = + account_check_sequence(vec![(401, "{}"), (200, ACCEPTED_ACCOUNT_CHECK)]); let client = ChatGptClient::with_base_url(&base_url).expect("client"); let identity = target.identity.clone().expect("identity"); let calls = Arc::new(Mutex::new(0_u8)); @@ -1251,34 +1247,233 @@ mod tests { || Ok(()), || { *counted.lock().expect("counter") += 1; - Ok(ManagedRefresh { - credential: refreshed_credential.clone(), - metadata: AccountMetadata { - kind: AccountKind::ChatGpt, - email: Some("person@example.com".into()), - plan_type: Some("plus".into()), - workspace_name: Some("Personal".into()), - account_structure: Some("workspace".into()), - }, - }) + keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + refreshed_credential.clone(), + ) }, ) .expect("refreshed"); assert_eq!(*calls.lock().expect("counter"), 1); assert_eq!(validated, refreshed_credential); + let requests = server.join().expect("server"); + assert_eq!(requests.len(), 2); + assert!(requests[0].contains("Bearer access-token")); + assert!(requests[1].contains("Bearer refreshed-token")); let saved = state .account_by_id_under_operation(&operation, &target.id) .expect("saved"); assert_eq!(saved.credential, refreshed_credential); - server.join().expect("server"); + assert_eq!(saved.workspace_name.as_deref(), Some("Updated")); drop(operation); let _ = fs::remove_dir_all(root); } #[test] - fn failed_protected_recovery_reports_sign_in_instead_of_claiming_recovery_exists() { + fn a_sign_in_rejected_again_after_the_refresh_attempt_needs_sign_in() { + for second_status in [401, 403] { + let (state, target, root) = state_with_chatgpt_target(); + let (base_url, server) = + account_check_sequence(vec![(401, "{}"), (second_status, "{}")]); + let client = ChatGptClient::with_base_url(&base_url).expect("client"); + let identity = target.identity.clone().expect("identity"); + let operation = state.acquire_operation().expect("operation"); + + // Codex ran but produced no newer credential, as it does for a + // revoked or already-used refresh token. + let error = validate_chatgpt_snapshot_with( + &state, + &operation, + &target, + &identity, + &client, + || Ok(()), + || { + keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + target.credential.clone(), + ) + }, + ) + .expect_err("rejected before and after the refresh attempt"); + + assert_eq!(error.code, SwitchFailureCode::AccountNeedsSignIn); + assert_eq!(server.join().expect("server").len(), 2); + let saved = state + .account_by_id_under_operation(&operation, &target.id) + .expect("saved"); + assert_eq!(saved.credential, target.credential); + assert_eq!(saved.credential_generation, target.credential_generation); + drop(operation); + let _ = fs::remove_dir_all(root); + } + } + + #[test] + fn an_unavailable_check_after_refresh_keeps_the_rotated_credential() { + for (status, body) in [(429, "{}"), (500, "{}"), (200, "not-json")] { + let (state, target, root) = state_with_chatgpt_target(); + let (base_url, server) = account_check_sequence(vec![(401, "{}"), (status, body)]); + let client = ChatGptClient::with_base_url(&base_url).expect("client"); + let identity = target.identity.clone().expect("identity"); + let refreshed_credential = credential("user", "workspace", "refreshed-token"); + let operation = state.acquire_operation().expect("operation"); + + let error = validate_chatgpt_snapshot_with( + &state, + &operation, + &target, + &identity, + &client, + || Ok(()), + || { + keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + refreshed_credential.clone(), + ) + }, + ) + .expect_err("provider unavailable after refresh"); + + assert_eq!(error.code, SwitchFailureCode::TargetCheckUnavailable); + assert_eq!(server.join().expect("server").len(), 2); + let saved = state + .account_by_id_under_operation(&operation, &target.id) + .expect("saved"); + assert_eq!(saved.credential, refreshed_credential); + assert!(saved.needs_apply); + drop(operation); + let _ = fs::remove_dir_all(root); + } + } + + #[test] + fn managed_refresh_failures_keep_unavailable_distinct_from_sign_in() { + for (failure, expected) in [ + ( + ManagedRefreshFailure::Unavailable("Unable to start Codex App Server".into()), + SwitchFailureCode::TargetCheckUnavailable, + ), + ( + ManagedRefreshFailure::IdentityChanged, + SwitchFailureCode::AccountNeedsSignIn, + ), + ( + ManagedRefreshFailure::NotSaved { + recovery_retained: true, + }, + SwitchFailureCode::RecoveryRequired, + ), + ( + ManagedRefreshFailure::NotSaved { + recovery_retained: false, + }, + SwitchFailureCode::AccountNeedsSignIn, + ), + ] { + let (state, target, root) = state_with_chatgpt_target(); + let (base_url, server) = account_check_server(401, r#"{}"#); + let client = ChatGptClient::with_base_url(&base_url).expect("client"); + let identity = target.identity.clone().expect("identity"); + let operation = state.acquire_operation().expect("operation"); + + let error = validate_chatgpt_snapshot_with( + &state, + &operation, + &target, + &identity, + &client, + || Ok(()), + || Err(failure), + ) + .expect_err("managed refresh failure"); + + assert_eq!(error.code, expected); + server.join().expect("server"); + drop(operation); + let _ = fs::remove_dir_all(root); + } + } + + #[test] + fn a_rotated_credential_is_saved_as_an_unapplied_login() { let (state, target, root) = state_with_chatgpt_target(); + let identity = target.identity.clone().expect("identity"); + let refreshed_credential = credential("user", "workspace", "refreshed-token"); + let operation = state.acquire_operation().expect("operation"); + + let unchanged = keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + target.credential.clone(), + ) + .expect("unchanged credential"); + assert_eq!(unchanged, target.credential); + let saved = state + .account_by_id_under_operation(&operation, &target.id) + .expect("saved"); + assert_eq!(saved.credential_generation, target.credential_generation); + assert!(!saved.needs_apply); + + let kept = keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + refreshed_credential.clone(), + ) + .expect("rotated credential"); + assert_eq!(kept, refreshed_credential); + let saved = state + .account_by_id_under_operation(&operation, &target.id) + .expect("saved"); + assert_eq!(saved.credential, refreshed_credential); + assert!(saved.credential_generation > target.credential_generation); + assert!(saved.needs_apply); + drop(operation); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn a_refresh_for_another_identity_is_never_saved() { + let (state, target, root) = state_with_chatgpt_target(); + let identity = target.identity.clone().expect("identity"); + let operation = state.acquire_operation().expect("operation"); + + for other in [ + credential("other-user", "workspace", "refreshed-token"), + credential("user", "other-workspace", "refreshed-token"), + json!({"OPENAI_API_KEY": "sk-fixture"}), + ] { + assert_eq!( + keep_refreshed_credential(&state, &operation, &target, &identity, other), + Err(ManagedRefreshFailure::IdentityChanged) + ); + } + let saved = state + .account_by_id_under_operation(&operation, &target.id) + .expect("saved"); + assert_eq!(saved.credential, target.credential); + drop(operation); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn failed_protected_recovery_is_not_reported_as_retained() { + let (state, target, root) = state_with_chatgpt_target(); + let identity = target.identity.clone().expect("identity"); let operation = state.acquire_operation().expect("operation"); let store_path = root.join("accounts.json"); let vault_path = root.join("credentials.hold"); @@ -1288,23 +1483,18 @@ mod tests { fs::remove_file(&vault_path).expect("remove vault snapshot"); fs::create_dir(&vault_path).expect("block protected recovery"); - let metadata = AccountMetadata { - kind: AccountKind::ChatGpt, - email: Some("person@example.com".into()), - plan_type: Some("plus".into()), - workspace_name: Some("Updated workspace".into()), - account_structure: Some("workspace".into()), - }; - let error = persist_switch_validation( - &state, - &operation, - &target, - &metadata, - Some(credential("user", "workspace", "rotated-token")), - ) - .expect_err("metadata and protected recovery writes both fail"); - - assert_eq!(error.code, SwitchFailureCode::AccountNeedsSignIn); + assert_eq!( + keep_refreshed_credential( + &state, + &operation, + &target, + &identity, + credential("user", "workspace", "rotated-token"), + ), + Err(ManagedRefreshFailure::NotSaved { + recovery_retained: false, + }) + ); drop(operation); let _ = fs::remove_dir_all(root); } diff --git a/src-tauri/src/wake.rs b/src-tauri/src/wake.rs index f5cde07..85946bd 100644 --- a/src-tauri/src/wake.rs +++ b/src-tauri/src/wake.rs @@ -13,8 +13,8 @@ use crate::{ accounts::{AppState, OperationGuard}, chatgpt::{ChatGptClient, WakeFailure, WakeFailureKind}, quota::{ - external_credential_state_for_identity, refresh_via_managed_profile_for_wake, - verified_chatgpt_identity, ExternalCredentialState, + external_credential_state_for_identity, refresh_saved_sign_in, verified_chatgpt_identity, + ExternalCredentialState, ManagedRefreshFailure, }, types::{ AccountIdentity, AccountKind, StoredAccount, WakeAccountResult, WakeOperationStatus, @@ -359,24 +359,26 @@ fn wake_account( let mut result = client.wake(&credential.value, model.id(), model.reasoning_effort()); // A definite authentication rejection cannot have completed a model turn. // Only a definitely inactive account may use one isolated official refresh; - // an uncertain send or an externally owned token is never retried. + // an uncertain send or an externally owned token is never retried. The + // repeated request, not the refresh, decides whether sign-in is needed. if result.as_ref().is_err_and(|error| { error.kind == WakeFailureKind::Authentication && credential.ownership == CredentialOwnership::Inactive }) { - let refreshed = - match refresh_via_managed_profile_for_wake(state, operation, account, &identity) { - Ok(refreshed) => refreshed, - Err(error) => { - let request_state = result - .as_ref() - .err() - .map_or(WakeRequestState::NotSent, |failure| failure.request_state); - return WakeAccountOutcome::new(WakeResultKind::NeedsSignIn, error) - .request_state(request_state); - } - }; - credential.value = refreshed.credential; + credential.value = match refresh_saved_sign_in(state, operation, account, &identity) { + Ok(refreshed) => refreshed, + Err(failure) => { + let request_state = result + .as_ref() + .err() + .map_or(WakeRequestState::NotSent, |failure| failure.request_state); + return WakeAccountOutcome::new( + managed_refresh_result(&failure), + failure.message(), + ) + .request_state(request_state); + } + }; if cancelled.load(Ordering::SeqCst) { return WakeAccountOutcome::new(WakeResultKind::Cancelled, "Wake was cancelled"); } @@ -392,6 +394,19 @@ fn wake_account( } } +fn managed_refresh_result(failure: &ManagedRefreshFailure) -> WakeResultKind { + match failure { + ManagedRefreshFailure::IdentityChanged + | ManagedRefreshFailure::NotSaved { + recovery_retained: false, + } => WakeResultKind::NeedsSignIn, + ManagedRefreshFailure::Unavailable(_) + | ManagedRefreshFailure::NotSaved { + recovery_retained: true, + } => WakeResultKind::Failed, + } +} + fn wake_failure(failure: WakeFailure) -> WakeAccountOutcome { let (result, message) = match failure.kind { WakeFailureKind::Transport | WakeFailureKind::InvalidResponse => ( @@ -539,6 +554,32 @@ mod tests { assert_eq!(credential.ownership, CredentialOwnership::External); } + #[test] + fn an_unavailable_refresh_is_not_reported_as_a_rejected_sign_in() { + assert_eq!( + managed_refresh_result(&ManagedRefreshFailure::Unavailable( + "Unable to start Codex App Server. Check that Codex is installed.".into(), + )), + WakeResultKind::Failed + ); + assert_eq!( + managed_refresh_result(&ManagedRefreshFailure::NotSaved { + recovery_retained: true, + }), + WakeResultKind::Failed + ); + assert_eq!( + managed_refresh_result(&ManagedRefreshFailure::IdentityChanged), + WakeResultKind::NeedsSignIn + ); + assert_eq!( + managed_refresh_result(&ManagedRefreshFailure::NotSaved { + recovery_retained: false, + }), + WakeResultKind::NeedsSignIn + ); + } + #[test] fn request_failures_preserve_whether_a_request_was_sent() { let uncertain = wake_failure(WakeFailure { From f0fea5cae96617364d75c29c91ba1dbcbe7bc9fc Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:21:11 +0800 Subject: [PATCH 2/2] Record the managed refresh owner in the architecture module list Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7c8e018..5030252 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,8 +75,8 @@ Keep the backend flat and organized by concrete responsibility: `intake.rs`; - `switching.rs`: live-account reconciliation, file-store enablement, switching, removal, and interrupted-switch recovery; -- `quota.rs`: quota normalization/cache, reset-credit selection, redemption, - and redemption recovery; +- `quota.rs`: quota normalization/cache, the managed refresh shared by switch, + quota, and Wake, reset-credit selection, redemption, and redemption recovery; - `wake.rs`: Wake policy, narrow Responses transport, sequential Wake All, cancellation, and per-account outcomes; - `runtime.rs`: external Codex process detection;