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
2 changes: 2 additions & 0 deletions src/utils/__tests__/usage-token-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'); });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Неблокирующее: замокан только read-side кэша (statSync). Сейчас write-путь здесь недостижим — единственный тест находит кандидата, — но будущий тест на full-miss записал бы реальный файл в ~/.cache разработчика. Стоит замокать writeFileSync и mkdirSync так же, как в usage-token.test.ts.

});

afterEach(() => {
Expand Down
45 changes: 45 additions & 0 deletions src/utils/__tests__/usage-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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'));
Expand Down
21 changes: 21 additions & 0 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
Loading