fix(rate-limits): a malformed Gemini oauth_creds.json is a failed read, not absent

readGeminiCredentials() returned null both when the file was missing and when it
was present but did not match the credential contract. The caller turns null into
status 'unavailable' / "credentials not found", so a corrupt or wrong-typed
oauth_creds.json was reported to the user as signed out — the failed read became
an absent one before the shared-quota rule could see it, and absent takes the
discard path.

Throw on the present-but-invalid case so it reaches the caller's error path, and
keep ENOENT returning null so an actually missing file still reads as absent.
Both sides of that boundary are now pinned.
This commit is contained in:
Merge Sim
2026-08-29 14:42:56 -07:00
parent 3169e05420
commit a79a76f5b5
2 changed files with 33 additions and 1 deletions
+3 -1
View File
@@ -68,7 +68,9 @@ export async function readGeminiCredentials(): Promise<GeminiCredentials | null>
) {
return parsed as GeminiCredentials
}
return null
// A file that exists but does not match the credential contract is a failed
// read. Preserve that distinction so callers do not report it as signed out.
throw new Error('Gemini CLI credentials file is invalid')
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
return null
@@ -153,6 +153,36 @@ describe('fetchGeminiRateLimits fallback oauth creds', () => {
expect(result.weekly).toBeNull()
})
it('reports a present but malformed oauth_creds.json as a failed read', async () => {
readFileMock.mockImplementation(async (filePath: string) => {
if (filePath.includes('auth.json')) {
return JSON.stringify({})
}
if (filePath.includes('oauth_creds.json')) {
return JSON.stringify({
access_token: 42,
refresh_token: 'refresh',
expiry_date: Date.now()
})
}
throw { code: 'ENOENT' }
})
const result = await fetchGeminiRateLimits(true)
expect(result.status).toBe('error')
expect(result.error).toContain('credentials file is invalid')
})
it('keeps an actually missing oauth_creds.json as absent', async () => {
readFileMock.mockRejectedValue({ code: 'ENOENT' })
const result = await fetchGeminiRateLimits(true)
expect(result.status).toBe('unavailable')
expect(result.error).toContain('credentials not found')
})
it('returns error when loadCodeAssist cannot resolve a project for oauth_creds path', async () => {
// Why: when the fallback (oauth_creds.json) path has no project embedded
// and loadCodeAssist fails, we surface a clear "project ID not found"