From ae1f8cb966fc857deaaa1091a703cb4f2c844dfa Mon Sep 17 00:00:00 2001 From: nguerrier Date: Tue, 1 Sep 2026 14:17:57 +0200 Subject: [PATCH 1/2] fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on macOS Claude Code stores each non-default profile's OAuth credential under its own keychain service, "Claude Code-credentials-", whenever CLAUDE_CONFIG_DIR is set. getUsageToken() only ever asked for the plain "Claude Code-credentials" item first, so a session running under another config dir reported the default profile's account in every widget that needs the usage API. Add getMacKeychainConfigDirService(), mirroring Claude Code's own service-name builder (CLAUDE_SECURESTORAGE_CONFIG_DIR override, empty value = plain name, NFC-normalized path), and try that service before the existing lookup chain. No change without CLAUDE_CONFIG_DIR. Refs #521 --- src/utils/__tests__/usage-token.test.ts | 113 ++++++++++++++++++++++++ src/utils/usage-fetch.ts | 25 +++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/usage-token.test.ts b/src/utils/__tests__/usage-token.test.ts index eddbc2e2..45026b5b 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,91 @@ 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('falls back to the plain keychain service when the CLAUDE_CONFIG_DIR entry is missing', () => { + 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) { + throw new Error(`Unexpected security args: ${args?.join(' ')}`); + } + + if (args[0] === 'find-generic-password' && args[2] === configDirService) { + throw new Error('missing profile credential'); + } + + if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials') { + return makeTokenPayload('exact-token'); + } + + throw new Error(`Unexpected security args: ${args.join(' ')}`); + }); + + expect(getUsageToken()).toBe('exact-token'); + expect(getSecurityCallLog()).toEqual([ + `find-generic-password -s ${configDirService} -w`, + 'find-generic-password -s Claude Code-credentials -w' + ]); + }); +}); + +describe('getMacKeychainConfigDirService', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/claude'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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 NFC-normalized directory, matching Claude Code', () => { + process.env.CLAUDE_CONFIG_DIR = '/fake/café'; + vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/café'); + + expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/café')); + }); + + 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..3ff5b14b 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -515,12 +515,35 @@ 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), +// and the directory is NFC-normalized before hashing. Mirrors Claude Code's +// own service-name builder (#521); returns null for the default profile. +export function getMacKeychainConfigDirService(): string | null { + const override = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + const isDefaultProfile = override !== undefined ? override === '' : !process.env.CLAUDE_CONFIG_DIR; + if (isDefaultProfile) { + return null; + } + + const configDir = (override ?? getClaudeConfigDir()).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(); } - return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) + // The active profile's own entry comes first; otherwise the plain service + // wins and a non-default profile silently reports the default account. + const configDirService = getMacKeychainConfigDirService(); + + return (configDirService ? readUsageTokenFromMacKeychainService(configDirService) : null) + ?? readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) ?? readUsageTokenFromMacKeychainCandidates() ?? readUsageTokenFromCredentialsFile(); } From 8531304d597b870bb3db82c80540d3e741e2fce0 Mon Sep 17 00:00:00 2001 From: nguerrier Date: Wed, 2 Sep 2026 11:40:47 +0200 Subject: [PATCH 2/2] fix(usage): hash the raw config-dir value; stop falling through to other profiles Review follow-ups on #573: - The service-name hash input is now the raw environment value (CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR), NFC-normalized but never resolved, matching Claude Code's builder exactly: its effectiveConfigDir is (raw ?? join(homedir(), ".claude")).normalize("NFC") with no path.resolve or existence check. Trailing slashes, relative paths and env values pointing at non-directories now hash to the same service name Claude Code emits. - When a config dir is active, a keychain miss no longer falls through to the plain service or the mtime-sorted candidate scan: those items all belong to other profiles or MCP servers, and surfacing them is the bug this PR fixes. Only the profile's own .credentials.json remains as a fallback. - usage-token-buffer.test.ts now sheds CLAUDE_CONFIG_DIR leaking in from the environment, since the candidate scan it exercises only runs for the default profile. --- .../__tests__/usage-token-buffer.test.ts | 15 +++++++ src/utils/__tests__/usage-token.test.ts | 43 +++++++------------ src/utils/usage-fetch.ts | 29 ++++++++----- 3 files changed, 48 insertions(+), 39 deletions(-) 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 45026b5b..9421f8d1 100644 --- a/src/utils/__tests__/usage-token.test.ts +++ b/src/utils/__tests__/usage-token.test.ts @@ -270,46 +270,28 @@ describe('getUsageToken', () => { ]); }); - it('falls back to the plain keychain service when the CLAUDE_CONFIG_DIR entry is missing', () => { + 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(); + 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] === configDirService) { + if (command === 'security' && args?.[0] === 'find-generic-password' && args[2] === configDirService) { throw new Error('missing profile credential'); } - if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials') { - return makeTokenPayload('exact-token'); - } - - throw new Error(`Unexpected security args: ${args.join(' ')}`); + throw new Error(`Unexpected security args: ${args?.join(' ')}`); }); - expect(getUsageToken()).toBe('exact-token'); + expect(getUsageToken()).toBe('file-token'); expect(getSecurityCallLog()).toEqual([ - `find-generic-password -s ${configDirService} -w`, - 'find-generic-password -s Claude Code-credentials -w' + `find-generic-password -s ${configDirService} -w` ]); }); }); describe('getMacKeychainConfigDirService', () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/claude'); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - it('returns null for the default profile', () => { expect(getMacKeychainConfigDirService()).toBeNull(); }); @@ -320,11 +302,16 @@ describe('getMacKeychainConfigDirService', () => { expect(getMacKeychainConfigDirService()).toBe(makeConfigDirService('/fake/claude')); }); - it('hashes the NFC-normalized directory, matching Claude Code', () => { - process.env.CLAUDE_CONFIG_DIR = '/fake/café'; - vi.spyOn(claudeSettings, 'getClaudeConfigDir').mockReturnValue('/fake/café'); + 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é')); + 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', () => { diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 3ff5b14b..db468e65 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -518,17 +518,18 @@ 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), -// and the directory is NFC-normalized before hashing. Mirrors Claude Code's -// own service-name builder (#521); returns null for the default profile. +// 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 override = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; - const isDefaultProfile = override !== undefined ? override === '' : !process.env.CLAUDE_CONFIG_DIR; - if (isDefaultProfile) { + const rawConfigDir = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR ?? ''; + if (rawConfigDir === '') { return null; } - const configDir = (override ?? getClaudeConfigDir()).normalize('NFC'); + const configDir = rawConfigDir.normalize('NFC'); const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8); return `${MACOS_USAGE_CREDENTIALS_SERVICE}-${suffix}`; } @@ -538,12 +539,18 @@ export function getUsageToken(): string | null { return readUsageTokenFromCredentialsFile(); } - // The active profile's own entry comes first; otherwise the plain service - // wins and a non-default profile silently reports the default account. 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 (configDirService ? readUsageTokenFromMacKeychainService(configDirService) : null) - ?? readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) + return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE) ?? readUsageTokenFromMacKeychainCandidates() ?? readUsageTokenFromCredentialsFile(); }