From d1088f0dc60244e2f69ef2f97aaf9425262ff3ea Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:32:51 -0300 Subject: [PATCH] fix(oauth): preserve non-rotating providers' refresh_token on unrecoverable refresh (#3679) (#3766) The proactive health-check refresh deactivation branch nulled the stored refresh_token on any unrecoverable error (e.g. invalid_grant). That was only meant for rotating one-time-use tokens (Codex/OpenAI); for non-rotating Google-family providers (gemini-cli/antigravity/gemini) it destroyed the user's only recovery artifact, leaving the connection permanently showing 'No valid refresh token available'. Gate the null on isRotatingProvider so non-rotating tokens are preserved. Closes #3679 --- CHANGELOG.md | 2 + src/lib/tokenHealthCheck.ts | 12 +++++- tests/unit/token-health-check.test.ts | 57 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e0d1826b..6c6b77b996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 🐛 Fixed +- fix(oauth): stop nulling the stored `refresh_token` of non-rotating providers when a proactive health-check refresh fails with `invalid_grant`. The destructive `refreshToken: null` write in `tokenHealthCheck` was only meant for rotating one-time-use tokens (Codex/OpenAI), but it also fired for Google-family providers (gemini-cli / antigravity / gemini) whose refresh tokens are non-rotating. Once nulled, the connection reported "No valid refresh token available" and could never recover even after re-activation. The token is now preserved (gated on `isRotatingProvider`) so it stays as the recovery artifact. ([#3679](https://github.com/diegosouzapw/OmniRoute/issues/3679) — thanks @3xa228148) + - fix(dashboard): self-host the Material Symbols icon font instead of loading it from the Google Fonts CDN. On networks where `fonts.googleapis.com` is unreachable (e.g. mainland China), the icon ligature font never loaded, so every icon rendered as its literal text name (`smart_toy`, `visibility`, …) and the layout broke — especially after importing many models. The font is now bundled locally via the `material-symbols` package (`@import "material-symbols/outlined.css"` in `globals.css`), removing the runtime CDN dependency. ([#3695](https://github.com/diegosouzapw/OmniRoute/issues/3695) — thanks @lqyiwwx) - fix(antigravity): skip Google One AI credits retry on `full_quota_exhausted` verdict — antigravity executor now calls `decide429()` before attempting the credits retry so that a quota-exhausted account (24h cooldown) bypasses the extra upstream HTTP call instead of hanging for up to ~41s. Also persists the cooldown in the DB via `setConnectionRateLimitUntil` so post-restart routing skips exhausted connections without re-learning the hard way. Bonus: `antigravity429Engine` now recognises the real Antigravity "Individual quota reached. Contact your administrator to enable overages." error message as `quota_exhausted`. ([#3707](https://github.com/diegosouzapw/OmniRoute/issues/3707) — thanks @andrea-kingautomation) diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 305d9ec6e5..9a71f18df4 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -543,13 +543,21 @@ export async function checkConnection(conn) { await updateProviderConnection(conn.id, { lastHealthCheckAt: now, testStatus: "expired", - lastError: `Refresh token consumed (${result.error}). Please re-authenticate this account.`, + lastError: isRotatingProvider + ? `Refresh token consumed (${result.error}). Please re-authenticate this account.` + : `Refresh token rejected (${result.error}). Please re-authenticate this account.`, lastErrorAt: now, lastErrorType: result.error, lastErrorSource: "oauth", errorCode: result.error, isActive: false, - refreshToken: null, + // Only rotating-token providers (Codex/OpenAI/etc.) have single-use refresh + // tokens that are genuinely consumed and worthless after a failed refresh, so + // clearing them is safe. For non-rotating providers (Google: gemini-cli / + // antigravity / gemini) the stored refresh_token is the user's only recovery + // artifact — nulling it caused #3679 (the connection reports "No valid refresh + // token available" and can never recover even after re-activation). Preserve it. + ...(isRotatingProvider ? { refreshToken: null } : {}), }); logError( `${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} — ` + diff --git a/tests/unit/token-health-check.test.ts b/tests/unit/token-health-check.test.ts index cb0b912d42..2fbf7569ba 100644 --- a/tests/unit/token-health-check.test.ts +++ b/tests/unit/token-health-check.test.ts @@ -469,3 +469,60 @@ test("checkConnection skips providers listed in OMNIROUTE_HEALTHCHECK_SKIP_PROVI if (prevSkip === undefined) delete process.env.OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS; else process.env.OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS = prevSkip; }); + +// Regression for #3679: a non-rotating (Google-family) provider whose proactive +// refresh fails with invalid_grant used to have its refresh_token NULLED, leaving +// the connection unrecoverable ("No valid refresh token available"). The null was +// only meant for rotating one-time-use tokens (Codex/OpenAI). Non-rotating providers +// must keep the stored refresh_token as the recovery artifact. +test("checkConnection preserves refresh_token for non-rotating providers on unrecoverable error (#3679)", async () => { + await resetStorage(); + + const providerId = "custom-nonrotating-3679"; // NOT in ROTATING_REFRESH_PROVIDERS + let refreshCount = 0; + + await withHttpServer( + (_req, res) => { + refreshCount += 1; + // Google returns invalid_grant → isUnrecoverableRefreshError() is true. + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_grant", error_description: "Bad Request" })); + }, + async (tokenServer) => { + await withPatchedProvider( + providerId, + { + tokenUrl: `${tokenServer.url}/token`, + clientId: "nonrotating-client-id", + clientSecret: "nonrotating-client-secret", + }, + async () => { + const connection = await providersDb.createProviderConnection({ + provider: providerId, + authType: "oauth", + name: "Non-rotating Account", + email: "nonrotating@example.com", + accessToken: "expired-access-token", + refreshToken: "rt-preserve-3679", + // Already expired → proactive refresh runs AND the still-valid guard fails, + // so execution reaches the deactivation branch that used to null the token. + expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + isActive: true, + }); + + await tokenHealthCheck.checkConnection(connection); + + const updated = await providersDb.getProviderConnectionById((connection as any).id); + assert.equal(refreshCount, 1, "the expired token must trigger a refresh attempt"); + assert.equal(updated?.testStatus, "expired", "should reach the unrecoverable branch"); + // The fix: the refresh_token is PRESERVED (was nulled before the fix). + assert.equal( + updated?.refreshToken, + "rt-preserve-3679", + "non-rotating provider must keep its refresh_token for recovery" + ); + } + ); + } + ); +});