diff --git a/src/utils/__tests__/usage-token-buffer.test.ts b/src/utils/__tests__/usage-token-buffer.test.ts index b6cb78286..db3970a1f 100644 --- a/src/utils/__tests__/usage-token-buffer.test.ts +++ b/src/utils/__tests__/usage-token-buffer.test.ts @@ -1,4 +1,5 @@ import * as childProcess from 'child_process'; +import * as fs from 'fs'; import { createRequire } from 'module'; import type { Mock } from 'vitest'; import { @@ -34,6 +35,7 @@ describe('getUsageToken dump-keychain behavior', () => { mockedExecFileSync.mockReset(); mockedExecFileSync.mockImplementation(realExecFileSync); vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + vi.spyOn(fs, 'statSync').mockImplementation(() => { throw new Error('cache missing'); }); }); afterEach(() => { diff --git a/src/utils/__tests__/usage-token.test.ts b/src/utils/__tests__/usage-token.test.ts index 38fabdaad..56c761d3f 100644 --- a/src/utils/__tests__/usage-token.test.ts +++ b/src/utils/__tests__/usage-token.test.ts @@ -138,6 +138,9 @@ describe('getUsageToken', () => { beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/claude'); + vi.spyOn(fs, 'statSync').mockImplementation(() => { throw new Error('cache missing'); }); + vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); + vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); mockedExecFileSync.mockReset(); }); @@ -242,6 +245,48 @@ describe('getUsageToken', () => { ]); }); + it('reuses empty keychain scans for 30 seconds without hiding direct credentials or file changes', () => { + let now = 100_000; + let missTime: number | undefined; + let exactToken = ''; + vi.spyOn(Date, 'now').mockImplementation(() => now); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + vi.spyOn(fs, 'statSync').mockImplementation(() => { + if (missTime === undefined) { + throw new Error('cache missing'); + } + return { mtimeMs: missTime } as fs.Stats; + }); + vi.spyOn(fs, 'writeFileSync').mockImplementation((filePath, data) => { + expect(String(filePath)).toMatch(/keychain-fallback-empty$/); + expect(data).toBe(''); + missTime = now; + }); + mockCredentialsFile(); + mockedExecFileSync.mockImplementation((_command: string, args: string[]) => { + return args[0] === 'dump-keychain' ? '' : exactToken ? makeTokenPayload(exactToken) : '{}'; + }); + + expect(getUsageToken()).toBeNull(); + now += 29_999; + expect(getUsageToken()).toBeNull(); + expect(getSecurityCallLog().filter(call => call === 'dump-keychain')).toHaveLength(1); + + now += 1; + expect(getUsageToken()).toBeNull(); + expect(getSecurityCallLog().filter(call => call === 'dump-keychain')).toHaveLength(2); + + now -= 1; + expect(getUsageToken()).toBeNull(); + expect(getSecurityCallLog().filter(call => call === 'dump-keychain')).toHaveLength(3); + + mockCredentialsFile(makeTokenPayload('file-token')); + expect(getUsageToken()).toBe('file-token'); + exactToken = 'new-login'; + expect(getUsageToken()).toBe('new-login'); + expect(getSecurityCallLog().filter(call => call === 'dump-keychain')).toHaveLength(3); + }); + it('uses the credentials file on non-macOS', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); mockCredentialsFile(makeTokenPayload('linux-file-token')); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 315212dde..c63e791b3 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -35,6 +35,8 @@ const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds const MAX_LOCK_HORIZON = 24 * 60 * 60; // seconds const MACOS_USAGE_CREDENTIALS_SERVICE = 'Claude Code-credentials'; const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; +const MACOS_KEYCHAIN_MISS_FILE = path.join(CACHE_DIR, 'keychain-fallback-empty'); +const MACOS_KEYCHAIN_MISS_TTL_MS = 30_000; export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] } @@ -548,6 +550,18 @@ function listMacKeychainCredentialCandidates(): string[] { } function readUsageCredentialsFromMacKeychainCandidates(): UsageCredentials | null { + // Each repaint is a new process. Remember empty scans across processes, + // while the exact service and credentials file still get checked every time. + // ponytail: new hashed logins may wait 30s; invalidate on keychain changes if instant detection is needed. + try { + const age = Date.now() - fs.statSync(MACOS_KEYCHAIN_MISS_FILE).mtimeMs; + if (age >= 0 && age < MACOS_KEYCHAIN_MISS_TTL_MS) { + return null; + } + } catch { + // No previous miss, or the cache is unavailable: scan normally. + } + const candidates = listMacKeychainCredentialCandidates(); for (const service of candidates) { @@ -557,6 +571,13 @@ function readUsageCredentialsFromMacKeychainCandidates(): UsageCredentials | nul } } + try { + ensureCacheDirExists(); + fs.writeFileSync(MACOS_KEYCHAIN_MISS_FILE, ''); + } catch { + // A read-only cache must not prevent credentials-file fallback. + } + return null; }