Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 33 additions & 8 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
40 changes: 29 additions & 11 deletions src-tauri/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>,
) -> Result<(), String> {
let mut store = self
.store
Expand All @@ -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
Expand All @@ -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(())
Expand Down
41 changes: 41 additions & 0 deletions src-tauri/src/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
self.call(
id,
Expand Down Expand Up @@ -569,6 +584,13 @@ fn should_retry_rate_limits_with_empty_object(error: &CallError) -> bool {
)
}

fn refresh_attempt_completed(result: Result<Value, CallError>) -> Result<(), String> {
match result {
Ok(_) | Err(CallError::Rejected { .. }) => Ok(()),
Err(error) => Err(error.sanitized()),
}
}

#[cfg(test)]
fn response_result(message: Value) -> Result<Value, String> {
response_result_protocol(message).map_err(CallError::sanitized)
Expand Down Expand Up @@ -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();
Expand Down
128 changes: 84 additions & 44 deletions src-tauri/src/quota.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ pub fn cached_quota(state: &AppState, account_id: &str) -> Result<QuotaView, Str
Ok(cached_view(&account, now_unix_ms()))
}

/// Reads capacity through an isolated official Codex App Server profile. A
/// successful call proves the saved ChatGPT credential can reach this official
/// account endpoint; `account/read` alone is deliberately not used as proof.
/// Reads capacity from ChatGPT's read-only usage endpoint. Only a rejected,
/// inactive saved sign-in may use one isolated official refresh, after which
/// the same read is repeated; `account/read` alone is deliberately not used
/// as proof that a credential reaches the provider.
pub fn refresh_quota(state: &AppState, account_id: &str) -> Result<QuotaView, String> {
let operation = state.acquire_operation()?;
let account = state.account_by_id_under_operation(&operation, account_id)?;
Expand All @@ -96,7 +97,10 @@ pub fn refresh_quota(state: &AppState, account_id: &str) -> Result<QuotaView, St
match refresh_read_only(state, &operation, &account, &identity, &account.credential) {
Ok(view) => 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()),
}
Expand Down Expand Up @@ -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<QuotaView, String> {
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<Value, ManagedRefreshFailure> {
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<ManagedQuotaRefresh, String> {
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<Value, ManagedRefreshFailure> {
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(),
})
}

Expand Down
Loading
Loading