Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/utils/__tests__/usage-token-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
100 changes: 100 additions & 0 deletions src/utils/__tests__/usage-token.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +14,7 @@ import {

import * as claudeSettings from '../claude-settings';
import {
getMacKeychainConfigDirService,
getUsageToken,
parseMacKeychainCredentialCandidates
} from '../usage-fetch';
Expand All @@ -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 } });
Expand Down Expand Up @@ -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();
});
});
30 changes: 30 additions & 0 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `-<sha256(configDir)[:8]>`, 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();
Expand Down