diff --git a/src/utils/__tests__/usage-token.test.ts b/src/utils/__tests__/usage-token.test.ts index eddbc2e2..35c1cb54 100644 --- a/src/utils/__tests__/usage-token.test.ts +++ b/src/utils/__tests__/usage-token.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import type { Mock } from 'vitest'; import { @@ -34,7 +35,7 @@ function encodeAsciiAsHex(value: string): string { return Buffer.from(value, 'utf8').toString('hex'); } -function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?: string }): string { +function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?: string }, account?: string): string { const lines = [ 'keychain: "/Users/example/Library/Keychains/login.keychain-db"', 'version: 512', @@ -43,6 +44,10 @@ function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted? ` "svce"="${service}"` ]; + if (account !== undefined) { + lines.push(` "acct"="${account}"`); + } + if (modifiedAt?.raw && modifiedAt.quoted) { lines.push(` "mdat"=0x${modifiedAt.raw} "${modifiedAt.quoted}"`); } else if (modifiedAt?.raw) { @@ -111,6 +116,7 @@ describe('getUsageToken', () => { beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/claude'); + vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'testuser' } as unknown as os.UserInfo); mockedExecFileSync.mockReset(); }); @@ -138,6 +144,64 @@ describe('getUsageToken', () => { ]); }); + it('reads the OS-username account when the service-only lookup returns an item without an OAuth token', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + mockCredentialsFile(); + mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { + if (command !== 'security' || !args) { + throw new Error(`Unexpected security args: ${args?.join(' ')}`); + } + + const isCredentialsService = args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials'; + if (isCredentialsService && args.includes('-a') && args[args.indexOf('-a') + 1] === 'testuser') { + return makeTokenPayload('account-token'); + } + + if (isCredentialsService) { + // Service-only match: an unrelated account's MCP-only item. + return JSON.stringify({ mcpOAuth: { some: 'value' } }); + } + + throw new Error(`Unexpected security args: ${args.join(' ')}`); + }); + + expect(getUsageToken()).toBe('account-token'); + expect(getSecurityCallLog()).toEqual([ + 'find-generic-password -s Claude Code-credentials -w', + 'find-generic-password -s Claude Code-credentials -a testuser -w' + ]); + }); + + it('skips the OS-username account read when the username cannot be resolved', () => { + // os.userInfo() throws when the uid has no passwd entry (some containers). + vi.spyOn(os, 'userInfo').mockImplementation(() => { + throw new Error('no passwd entry'); + }); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + mockCredentialsFile(makeTokenPayload('file-token')); + mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { + if (command !== 'security' || !args) { + throw new Error(`Unexpected security args: ${args?.join(' ')}`); + } + + if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials') { + throw new Error('missing exact credential'); + } + + if (args[0] === 'dump-keychain') { + return ''; + } + + throw new Error(`Unexpected security args: ${args.join(' ')}`); + }); + + expect(getUsageToken()).toBe('file-token'); + expect(getSecurityCallLog()).toEqual([ + 'find-generic-password -s Claude Code-credentials -w', + 'dump-keychain' + ]); + }); + it('tries the newest hashed macOS keychain candidate after an exact miss', () => { const dump = [ makeKeychainBlock('Claude Code-credentials-old', { quoted: '20240201010101Z' }), @@ -170,9 +234,11 @@ describe('getUsageToken', () => { expect(getUsageToken()).toBe('hashed-token'); expect(getSecurityCallLog()).toEqual([ 'find-generic-password -s Claude Code-credentials -w', + 'find-generic-password -s Claude Code-credentials -a testuser -w', 'dump-keychain', 'find-generic-password -s Claude Code-credentials-new -w', 'find-generic-password -s Claude Code-credentials -w', + 'find-generic-password -s Claude Code-credentials -a testuser -w', 'dump-keychain', 'find-generic-password -s Claude Code-credentials-new -w' ]); @@ -207,9 +273,11 @@ describe('getUsageToken', () => { expect(getUsageToken()).toBe('file-token'); expect(getSecurityCallLog()).toEqual([ 'find-generic-password -s Claude Code-credentials -w', + 'find-generic-password -s Claude Code-credentials -a testuser -w', 'dump-keychain', 'find-generic-password -s Claude Code-credentials-hashed -w', 'find-generic-password -s Claude Code-credentials -w', + 'find-generic-password -s Claude Code-credentials -a testuser -w', 'dump-keychain', 'find-generic-password -s Claude Code-credentials-hashed -w' ]); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 5f252b07..ced105bd 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -339,11 +339,14 @@ export function parseMacKeychainCredentialCandidates(rawDump: string, servicePre .map(candidate => candidate.service); } -function readMacKeychainSecret(service: string): string | null { +function readMacKeychainSecret(service: string, account?: string): string | null { try { + const args = account + ? ['find-generic-password', '-s', service, '-a', account, '-w'] + : ['find-generic-password', '-s', service, '-w']; return execFileSync( 'security', - ['find-generic-password', '-s', service, '-w'], + args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true } ).trim(); } catch { @@ -351,11 +354,28 @@ function readMacKeychainSecret(service: string): string | null { } } -function readUsageTokenFromMacKeychainService(service: string): string | null { - const secret = readMacKeychainSecret(service); +function readUsageTokenFromMacKeychainService(service: string, account?: string): string | null { + const secret = readMacKeychainSecret(service, account); return secret ? parseUsageAccessToken(secret) : null; } +function getOsUsername(): string | null { + try { + return os.userInfo().username; + } catch { + return null; + } +} + +// The login keychain can hold several items under one service, differing only by +// account; a service-only lookup returns whichever matches first, which may be an +// entry lacking `claudeAiOauth`. Claude Code reads/writes with an explicit account +// (the OS username), so mirror that. +function readUsageTokenFromMacKeychainUserAccount(service: string): string | null { + const osUsername = getOsUsername(); + return osUsername ? readUsageTokenFromMacKeychainService(service, osUsername) : null; +} + function listMacKeychainCredentialCandidates(): string[] { try { const rawDump = execFileSync( @@ -403,6 +423,7 @@ export function getUsageToken(): string | null { } return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) + ?? readUsageTokenFromMacKeychainUserAccount(MACOS_USAGE_CREDENTIALS_SERVICE) ?? readUsageTokenFromMacKeychainCandidates() ?? readUsageTokenFromCredentialsFile(); }