From 642ec947f10086ae956015a12ee35728ecf72b0b Mon Sep 17 00:00:00 2001 From: Rai Butera Date: Thu, 6 Aug 2026 12:03:50 +0100 Subject: [PATCH] claude login reads the shared keychain entry when the CLI doesn't namespace the config dir --- CHANGELOG.md | 1 + package.json | 2 +- src/claude.test.ts | 25 +++++++++++++++++++++++++ src/claude.ts | 43 +++++++++++++++++++++++++++++++------------ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f144cc..17e927c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-08-06] claude login reads the shared keychain entry when the CLI doesn't namespace the config dir - [2026-07-23] an out-of-date dashboard says restart and refuses changes - [2026-07-23] claude routing keeps the native login, fixes #15 - [2026-07-22] session reset time on analytics, fixes #9 diff --git a/package.json b/package.json index 1e20747..4e156d2 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.58" + "version": "0.0.59" } diff --git a/src/claude.test.ts b/src/claude.test.ts index 1c4f135..0bdb270 100644 --- a/src/claude.test.ts +++ b/src/claude.test.ts @@ -67,6 +67,31 @@ describe('registerClaudeAccount', () => { expect(account.externalAccountId).toBe('account-uuid') expect(vault.items.get(account.secretReference ?? '')).toBe(JSON.stringify(stored)) }) + + test('falls back to the shared keychain service when the CLI does not namespace by config dir', async () => { + const vault = memoryVault({}) + const account = await registerClaudeAccount({ + dependencies: { + captured: async command => { + if (command[1] !== 'find-generic-password') { + return { exitCode: 0, stdout: '' } + } + // Current Claude Code writes the shared, un-namespaced service and + // leaves no config-dir-namespaced entry; the namespaced lookup misses. + const service = command[command.indexOf('-s') + 1] + return service === 'Claude Code-credentials' + ? { exitCode: 0, stdout: JSON.stringify({ claudeAiOauth: stored }) } + : { exitCode: 44, stdout: '' } + }, + interactive: async () => ({ exitCode: 0, stderr: '' }) + }, + fetchImplementation: async () => + Response.json({ account: { email: 'Lennard@Example.com', uuid: 'account-uuid' } }), + vault + }) + expect(account.externalAccountId).toBe('account-uuid') + expect(vault.items.get(account.secretReference ?? '')).toBe(JSON.stringify(stored)) + }) }) describe('refreshClaudeCredential', () => { diff --git a/src/claude.ts b/src/claude.ts index 4fec5bd..1051704 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -119,12 +119,34 @@ function currentUser(): string { return process.env.USER ?? userInfo().username } +// The shared, un-namespaced service the Claude CLI writes on macOS. Current +// Claude Code versions store the login here regardless of CLAUDE_CONFIG_DIR. +const sharedKeychainService = 'Claude Code-credentials' + +// Older Claude CLIs namespaced the Keychain service by CLAUDE_CONFIG_DIR; this +// reproduces that name so an isolated profile can still be read on those builds. function cliKeychainService(profilePath: string): string { const canonical = normalize(resolve(profilePath)).normalize('NFC') const digest = new Bun.CryptoHasher('sha256').update(canonical).digest('hex') return `Claude Code-credentials-${digest.slice(0, 8)}` } +async function readKeychainCredential( + dependencies: ClaudeLoginDependencies, + service: string +): Promise { + const keychain = await dependencies.captured([ + 'security', + 'find-generic-password', + '-a', + currentUser(), + '-s', + service, + '-w' + ]) + return keychain.exitCode === 0 ? decodeSecurityOutput(keychain.stdout) : null +} + function decodeSecurityOutput(output: string): string { const trimmed = output.replace(/\n$/, '') if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { @@ -140,19 +162,13 @@ async function importCliCredential( profilePath: string, dependencies: ClaudeLoginDependencies ): Promise { - const keychain = await dependencies.captured([ - 'security', - 'find-generic-password', - '-a', - currentUser(), - '-s', - cliKeychainService(profilePath), - '-w' - ]) + // Read whichever store the CLI actually wrote: the config-dir-namespaced + // service (old CLIs), then the shared service (current CLIs, which ignore + // CLAUDE_CONFIG_DIR for Keychain storage), then the on-disk fallback. const serialized = - keychain.exitCode === 0 - ? decodeSecurityOutput(keychain.stdout) - : await readFile(join(profilePath, '.credentials.json'), 'utf8').catch(() => null) + (await readKeychainCredential(dependencies, cliKeychainService(profilePath))) ?? + (await readKeychainCredential(dependencies, sharedKeychainService)) ?? + (await readFile(join(profilePath, '.credentials.json'), 'utf8').catch(() => null)) if (serialized === null) { throw new ApplicationError( 'CREDENTIAL_MISSING', @@ -181,6 +197,9 @@ export async function removeClaudeProfile( profilePath: string, dependencies: ClaudeLoginDependencies = defaultClaudeLoginDependencies() ): Promise { + // Only delete the namespaced service tokenmaxx may have created. Never touch + // the shared `Claude Code-credentials` entry: on current CLIs that is the + // user's own Claude Code login, which the isolated `claude auth login` wrote. await dependencies.captured([ 'security', 'delete-generic-password',