diff --git a/src/utils/__tests__/usage-token-buffer.test.ts b/src/utils/__tests__/usage-token-buffer.test.ts index e4f54615..b6cb7828 100644 --- a/src/utils/__tests__/usage-token-buffer.test.ts +++ b/src/utils/__tests__/usage-token-buffer.test.ts @@ -22,14 +22,29 @@ const require = createRequire(import.meta.url); const { execFileSync: realExecFileSync } = require('node:child_process') as { execFileSync: typeof childProcess.execFileSync }; const mockedExecFileSync = childProcess.execFileSync as Mock; +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; +const ORIGINAL_SECURESTORAGE_CONFIG_DIR = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + describe('getUsageToken dump-keychain behavior', () => { beforeEach(() => { + // The candidate scan under test only runs for the default profile, so + // shed any config-dir variables leaking in from the environment. + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; mockedExecFileSync.mockReset(); mockedExecFileSync.mockImplementation(realExecFileSync); vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); }); afterEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + if (ORIGINAL_CLAUDE_CONFIG_DIR !== undefined) { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR; + } + if (ORIGINAL_SECURESTORAGE_CONFIG_DIR !== undefined) { + process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR = ORIGINAL_SECURESTORAGE_CONFIG_DIR; + } vi.restoreAllMocks(); mockedExecFileSync.mockReset(); mockedExecFileSync.mockImplementation(realExecFileSync); diff --git a/src/utils/__tests__/usage-token.test.ts b/src/utils/__tests__/usage-token.test.ts index eddbc2e2..9421f8d1 100644 --- a/src/utils/__tests__/usage-token.test.ts +++ b/src/utils/__tests__/usage-token.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import type { Mock } from 'vitest'; @@ -13,6 +14,7 @@ import { import * as claudeSettings from '../claude-settings'; import { + getMacKeychainConfigDirService, getUsageToken, parseMacKeychainCredentialCandidates } from '../usage-fetch'; @@ -25,6 +27,30 @@ vi.mock('child_process', () => ({ const CREDENTIALS_FILE = path.join('/fake/claude', '.credentials.json'); const mockedExecFileSync = execFileSync as unknown as Mock; +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; +const ORIGINAL_SECURESTORAGE_CONFIG_DIR = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + +// The config-dir lookup keys off these variables, so every test starts from +// the default (unset) profile regardless of the environment running the suite. +beforeEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; +}); + +afterEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + if (ORIGINAL_CLAUDE_CONFIG_DIR !== undefined) { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR; + } + if (ORIGINAL_SECURESTORAGE_CONFIG_DIR !== undefined) { + process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR = ORIGINAL_SECURESTORAGE_CONFIG_DIR; + } +}); + +function makeConfigDirService(configDir: string): string { + return `Claude Code-credentials-${createHash('sha256').update(configDir).digest('hex').slice(0, 8)}`; +} function makeTokenPayload(token: string): string { return JSON.stringify({ claudeAiOauth: { accessToken: token } }); @@ -223,4 +249,78 @@ describe('getUsageToken', () => { expect(getUsageToken()).toBe('linux-file-token'); expect(mockedExecFileSync).not.toHaveBeenCalled(); }); + + it('reads the CLAUDE_CONFIG_DIR keychain service first and skips the plain service on a hit', () => { + const configDirService = makeConfigDirService('/fake/claude'); + + process.env.CLAUDE_CONFIG_DIR = '/fake/claude'; + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + mockCredentialsFile(); + mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { + if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) { + return makeTokenPayload('profile-token'); + } + + throw new Error(`Unexpected security args: ${args?.join(' ')}`); + }); + + expect(getUsageToken()).toBe('profile-token'); + expect(getSecurityCallLog()).toEqual([ + `find-generic-password -s ${configDirService} -w` + ]); + }); + + it('skips other profiles\' keychain items and uses the profile\'s credentials file when its entry is missing', () => { + const configDirService = makeConfigDirService('/fake/claude'); + + process.env.CLAUDE_CONFIG_DIR = '/fake/claude'; + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + mockCredentialsFile(makeTokenPayload('file-token')); + mockedExecFileSync.mockImplementation((command: string, args?: string[]) => { + if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) { + throw new Error('missing profile credential'); + } + + throw new Error(`Unexpected security args: ${args?.join(' ')}`); + }); + + expect(getUsageToken()).toBe('file-token'); + expect(getSecurityCallLog()).toEqual([ + `find-generic-password -s ${configDirService} -w` + ]); + }); +}); + +describe('getMacKeychainConfigDirService', () => { + it('returns null for the default profile', () => { + expect(getMacKeychainConfigDirService()).toBeNull(); + }); + + it('suffixes the service with the first 8 hex chars of sha256(config dir) when CLAUDE_CONFIG_DIR is set', () => { + process.env.CLAUDE_CONFIG_DIR = '/fake/claude'; + + expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/claude')); + }); + + it('hashes the raw environment value without resolving it, matching Claude Code', () => { + process.env.CLAUDE_CONFIG_DIR = '/fake/claude-work/'; + + expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/claude-work/')); + }); + + it('NFC-normalizes the directory before hashing, matching Claude Code', () => { + process.env.CLAUDE_CONFIG_DIR = '/fake/cafe\u0301'; + + expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/caf\u00e9')); + }); + + it('lets CLAUDE_SECURESTORAGE_CONFIG_DIR override the hash input, and an empty override forces the plain service', () => { + process.env.CLAUDE_CONFIG_DIR = '/fake/claude'; + + process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR = '/fake/secure'; + expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/secure')); + + process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''; + expect(getMacKeychainConfigDirService()).toBeNull(); + }); }); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index e2f48285..db468e65 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -515,11 +515,41 @@ function readUsageTokenFromCredentialsFile(): string | null { } } +// Claude Code stores each non-default profile's credential under its own +// keychain service: the plain name plus `-`, added +// whenever CLAUDE_CONFIG_DIR is set. CLAUDE_SECURESTORAGE_CONFIG_DIR, when +// present, replaces the hash input (an empty value forces the plain name). +// The hash input is the raw environment value, NFC-normalized but not +// resolved — the goal is to reproduce the exact string Claude Code's own +// service-name builder hashes, not to locate a directory (#521). Returns +// null for the default profile. +export function getMacKeychainConfigDirService(): string | null { + const rawConfigDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? ''; + if (rawConfigDir === '') { + return null; + } + + const configDir = rawConfigDir.normalize('NFC'); + const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8); + return `${MACOS_USAGE_CREDENTIALS_SERVICE}-${suffix}`; +} + export function getUsageToken(): string | null { if (process.platform !== 'darwin') { return readUsageTokenFromCredentialsFile(); } + const configDirService = getMacKeychainConfigDirService(); + if (configDirService !== null) { + // A non-default profile owns exactly one keychain service name. On a + // miss, the plain service and the other suffixed items all belong to + // other profiles (or MCP servers), so fall through only to the + // profile's own .credentials.json rather than surface another + // account's usage. + return readUsageTokenFromMacKeychainService(configDirService) + ?? readUsageTokenFromCredentialsFile(); + } + return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) ?? readUsageTokenFromMacKeychainCandidates() ?? readUsageTokenFromCredentialsFile();