From 49e7d2ea52502f52b72f92ce56b9374d08530440 Mon Sep 17 00:00:00 2001 From: emotionalboySY Date: Tue, 4 Aug 2026 10:01:36 +0900 Subject: [PATCH] fix(usage): fingerprint the usage cache by refresh token The cache fingerprint hashes the OAuth access token, which is reissued on a short cycle (observed ~8h expiry), so an ordinary refresh of the very same account changes it and the cached snapshot is discarded as if the account had switched. That is worst exactly when the cache is load-bearing. While a fetch failure is being backed off, getStaleUsageOrError finds the fingerprint mismatched, drops an otherwise valid snapshot and returns error text for the whole window - observed as [Rate limited] on every usage widget for a server-issued Retry-After of 3600s, with complete usage data sitting in usage.json the entire time. Fingerprint the refresh token instead: it identifies the login rather than the session (observed ~13 day expiry, two orders of magnitude longer), so the account-switch guard stays intact without churning on every refresh. It falls back to the access token when absent, keeping older credential files and access-token-only keychain entries working as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/__tests__/usage-fetch.test.ts | 114 ++++++++++++++++++++++++ src/utils/usage-fetch.ts | 94 +++++++++++++------ 2 files changed, 182 insertions(+), 26 deletions(-) diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 8163f014..53c73814 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -256,6 +256,20 @@ function parseLockContents(lockContents: string | null): { blockedUntil: number; return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null; } +// createTokenHome writes an access-token-only credentials file; these tests +// need a refresh token alongside it to exercise the account fingerprint. +function writeUsageCredentials(claudeConfig: string, claudeAiOauth: { accessToken: string; refreshToken?: string }): void { + fs.writeFileSync(path.join(claudeConfig, '.credentials.json'), JSON.stringify({ claudeAiOauth })); +} + +function seedUsageCache(home: string, contents: Record): { cacheFile: string; mtimeMs: number } { + const cacheDir = path.join(home, '.cache', 'ccstatusline'); + fs.mkdirSync(cacheDir, { recursive: true }); + const cacheFile = path.join(cacheDir, 'usage.json'); + fs.writeFileSync(cacheFile, JSON.stringify(contents)); + return { cacheFile, mtimeMs: fs.statSync(cacheFile).mtimeMs }; +} + describe('fetchUsageData error handling', () => { const nowMs = 2200000000000; const successResponseBody = JSON.stringify({ @@ -993,6 +1007,106 @@ describe('fetchUsageData error handling', () => { } }); + it('serves a fresh cache after the access token was refreshed (same login)', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('access-token-refreshed'); + // The access token was reissued since the cache was written; the + // refresh token, which identifies the login, is unchanged. + writeUsageCredentials(home.claudeConfig, { + accessToken: 'reissued-access-token', + refreshToken: 'stable-refresh-token' + }); + const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: mtimeMs + 5000, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + // A refresh must not look like an account switch: the cache stands + // and no request is made. + expect(result.requestCount).toBe(0); + expect(result.first.sessionUsage).toBe(5); + } finally { + harness.cleanup(); + } + }); + + it('serves a stale cache through a rate-limit backoff after the access token was refreshed', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('access-token-refreshed-rate-limit'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'reissued-access-token', + refreshToken: 'stable-refresh-token' + }); + const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint }); + + // Cache is past CACHE_MAX_AGE and a server-issued backoff is active, + // so the stale-cache fallback is the only thing standing between the + // widgets and error text for the rest of the window. + const backedOffNowMs = mtimeMs + 200000; + fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({ + blockedUntil: Math.floor(backedOffNowMs / 1000) + 3600, + error: 'rate-limited' + })); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: backedOffNowMs, + pathDir: home.bin, + requiredFields: ['sessionUsage'] + }); + + expect(result.first).toEqual({ sessionUsage: 5 }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + + it('refetches when the refresh token changes (account switch)', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('refresh-token-switched'); + writeUsageCredentials(home.claudeConfig, { + accessToken: 'other-account-access-token', + refreshToken: 'other-account-refresh-token' + }); + const previousFingerprint = createHash('sha256').update('previous-refresh-token').digest('hex').slice(0, 16); + const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: previousFingerprint }); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: mtimeMs + 5000, + pathDir: home.bin, + requiredFields: ['sessionUsage'], + responseBody: successResponseBody + }); + + // A different login still invalidates the cache immediately. + expect(result.requestCount).toBe(1); + expect(result.first.sessionUsage).toBe(42); + } finally { + harness.cleanup(); + } + }); + it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => { const harness = createProbeHarness(); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index e2f48285..0cff2536 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -31,6 +31,13 @@ const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] } +// The access token is what the API is called with; the refresh token is kept +// alongside it only to fingerprint the account (see fingerprintUsageCredentials). +interface UsageCredentials { + accessToken: string; + refreshToken?: string; +} + const EXTRA_USAGE_DETAIL_FIELDS = new Set([ 'extraUsageLimit', 'extraUsageUsed', @@ -55,7 +62,12 @@ const WINDOW_RESET_FIELD_SENTINELS: Partial [bucket.resetField, bucket.usageField])) }; -const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); +const UsageCredentialsSchema = z.object({ + claudeAiOauth: z.object({ + accessToken: z.string().nullable().optional(), + refreshToken: z.string().nullable().optional() + }).optional() +}); const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited', 'parse-error']); const UsageLockSchema = z.object({ blockedUntil: z.number(), @@ -180,9 +192,16 @@ function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null } } -function parseUsageAccessToken(rawJson: string): string | null { - const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema); - return parsed?.claudeAiOauth?.accessToken ?? null; +function parseUsageCredentials(rawJson: string): UsageCredentials | null { + const oauth = parseJsonWithSchema(rawJson, UsageCredentialsSchema)?.claudeAiOauth; + if (!oauth?.accessToken) { + return null; + } + + return { + accessToken: oauth.accessToken, + refreshToken: oauth.refreshToken ?? undefined + }; } function parseCachedUsageData(rawJson: string): UsageData | null { @@ -213,14 +232,32 @@ function parseCachedUsageData(rawJson: string): UsageData | null { }; } -// One-way fingerprint of the usage token, persisted alongside the cache so a -// login switch (e.g. enterprise<->personal, a different token) invalidates the -// cache immediately instead of waiting out the TTL. A truncated SHA-256 is a -// stable identifier, not the token itself, so it is safe to write to disk. +// One-way fingerprint of the usage credentials, persisted alongside the cache +// so a login switch (e.g. enterprise<->personal, a different account) +// invalidates the cache immediately instead of waiting out the TTL. A +// truncated SHA-256 is a stable identifier, not the token itself, so it is +// safe to write to disk. function fingerprintUsageToken(token: string): string { return createHash('sha256').update(token).digest('hex').slice(0, 16); } +// The access token is reissued on a short cycle (observed: ~8h expiry), so +// hashing it makes the fingerprint change on an ordinary refresh of the very +// same account. That is indistinguishable from a login switch here, and it +// discards an otherwise valid cache - which matters most exactly when the +// cache is load-bearing, i.e. while a fetch failure is being backed off: the +// widgets then degrade to error text for the whole window (up to the server's +// Retry-After) even though the cached reading is the user's own and current. +// +// The refresh token identifies the login rather than the session (observed: +// ~13 day expiry, two orders of magnitude longer), so fingerprinting it keeps +// the account-switch guard intact without churning on every refresh. It falls +// back to the access token when absent, which keeps older credential files and +// any keychain entry that only stores an access token working as before. +function fingerprintUsageCredentials(credentials: UsageCredentials): string { + return fingerprintUsageToken(credentials.refreshToken ?? credentials.accessToken); +} + function readCachedTokenHash(rawJson: string): string | undefined { return parseJsonWithSchema(rawJson, CachedTokenHashSchema)?.tokenHash; } @@ -469,9 +506,9 @@ function readMacKeychainSecret(service: string): string | null { } } -function readUsageTokenFromMacKeychainService(service: string): string | null { +function readUsageCredentialsFromMacKeychainService(service: string): UsageCredentials | null { const secret = readMacKeychainSecret(service); - return secret ? parseUsageAccessToken(secret) : null; + return secret ? parseUsageCredentials(secret) : null; } function listMacKeychainCredentialCandidates(): string[] { @@ -493,36 +530,40 @@ function listMacKeychainCredentialCandidates(): string[] { } } -function readUsageTokenFromMacKeychainCandidates(): string | null { +function readUsageCredentialsFromMacKeychainCandidates(): UsageCredentials | null { const candidates = listMacKeychainCredentialCandidates(); for (const service of candidates) { - const token = readUsageTokenFromMacKeychainService(service); - if (token) { - return token; + const credentials = readUsageCredentialsFromMacKeychainService(service); + if (credentials) { + return credentials; } } return null; } -function readUsageTokenFromCredentialsFile(): string | null { +function readUsageCredentialsFromCredentialsFile(): UsageCredentials | null { try { const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); - return parseUsageAccessToken(fs.readFileSync(credFile, 'utf8')); + return parseUsageCredentials(fs.readFileSync(credFile, 'utf8')); } catch { return null; } } -export function getUsageToken(): string | null { +export function getUsageCredentials(): UsageCredentials | null { if (process.platform !== 'darwin') { - return readUsageTokenFromCredentialsFile(); + return readUsageCredentialsFromCredentialsFile(); } - return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) - ?? readUsageTokenFromMacKeychainCandidates() - ?? readUsageTokenFromCredentialsFile(); + return readUsageCredentialsFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) + ?? readUsageCredentialsFromMacKeychainCandidates() + ?? readUsageCredentialsFromCredentialsFile(); +} + +export function getUsageToken(): string | null { + return getUsageCredentials()?.accessToken ?? null; } function readStaleUsageCache(currentTokenHash: string | null): UsageData | null { @@ -716,12 +757,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi } } - // Resolve the token up front (before lock/rate-limit checks so auth - // failures are not masked as timeout) and fingerprint it so the file cache - // can be invalidated on an account switch: a different token, written by a + // Resolve the credentials up front (before lock/rate-limit checks so auth + // failures are not masked as timeout) and fingerprint them so the file cache + // can be invalidated on an account switch: a different login, written by a // logout/login, no longer matches the cached fingerprint. - const token = getUsageToken(); - const currentTokenHash = token ? fingerprintUsageToken(token) : null; + const credentials = getUsageCredentials(); + const token = credentials?.accessToken ?? null; + const currentTokenHash = credentials ? fingerprintUsageCredentials(credentials) : null; // Check file cache try {