From dcfba474c6f2f0bcca96afd2bb0bb456eb2a5f1f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:47:07 +0000 Subject: [PATCH] fix(claude-accounts): refuse switching to an account whose Claude login is gone Selection checked only the account's identity file, so an account with .claude.json intact but no usable login in its Keychain item or credentials file passed, and every chat that follows the selection failed its next start as signed out. Select now makes the same credential read the usage meter makes for the account; an unreadable store still allows the switch. --- .../claude-account-service.test.ts | 21 ++++++++++++++++ .../selection/claude-account-selection.ts | 24 +++++++++++++++++-- .../claude/claude-oauth-credentials.ts | 6 ++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 944c48015d8..defc0da2de8 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -11,6 +11,12 @@ import type { ClaudeCommandConfig, ClaudeCommandOptions } from './sign-in/claude import type { ClaudeAccountSelectionTarget } from './selection/runtime-selection' import { ClaudeAccountService } from './service' +// Selection checks must never reach the real Keychain. +vi.mock('../macos-keychain/generic-password', async (importOriginal) => ({ + ...(await importOriginal()), + readKeychainPassword: async () => null +})) + const roots: string[] = [] afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) @@ -190,4 +196,19 @@ describe('ClaudeAccountService', () => { expect(f.runtimeAuth.removeAccountFolder).not.toHaveBeenCalled() expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) }) + + it('refuses to select an account that has its identity but no login to start Claude with', async () => { + const f = fixture() + f.signIn('b', 'b@example.test') + await expect(f.service.selectAccount('b')).rejects.toThrow( + 'Sign in to this Claude account again before selecting it.' + ) + expect(f.settings().activeClaudeManagedAccountId).toBe('a') + + writeFileSync( + join(f.home('b'), '.credentials.json'), + JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh' } }) + ) + await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' }) + }) }) diff --git a/src/main/claude-accounts/selection/claude-account-selection.ts b/src/main/claude-accounts/selection/claude-account-selection.ts index f1867ab0005..7fb0f91260e 100644 --- a/src/main/claude-accounts/selection/claude-account-selection.ts +++ b/src/main/claude-accounts/selection/claude-account-selection.ts @@ -6,6 +6,8 @@ import type { import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RateLimitService } from '../../rate-limits/service/rate-limit-service' import { claudeStateFile, readClaudeFolderLogin } from '../sign-in/claude-account-folder' +import { readClaudeOAuthCredentials } from '../../rate-limits/providers/claude/claude-oauth-credentials' +import { resolveLoginShellEnvironment } from '../../startup/shell-environment/login-shell-environment' import { savedWslClaudeAccountHome } from '../profiles/wsl/claude-profile-wsl-paths' import type { ClaudeRuntimeAuthService } from '../runtime-auth-service' import { @@ -193,8 +195,13 @@ export class ClaudeAccountSelection { ) { throw new Error('That Claude account belongs to a different runtime.') } - // CLI and paired clients bypass the renderer's sign-in check. - if (this.summarize(account).needsSignIn) { + // CLI and paired clients bypass the renderer's sign-in check. An identity file alone can't + // start Claude, and chats follow the selection on their next start. + const summary = this.summarize(account) + if ( + summary.needsSignIn || + (summary.home && !summary.runsOnSystemDefault && (await accountHoldsNoLogin(summary.home))) + ) { throw new Error(CLAUDE_ACCOUNT_NEEDS_SIGN_IN_MESSAGE) } effectiveTarget = accountTarget @@ -271,3 +278,16 @@ export class ClaudeAccountSelection { } } } + +/** The read usage already makes for this account; an unreadable store is no answer. */ +async function accountHoldsNoLogin(home: string): Promise { + // Claude then keeps every login elsewhere, so an empty account folder proves nothing. + if ((await resolveLoginShellEnvironment()).CLAUDE_SECURESTORAGE_CONFIG_DIR !== undefined) { + return false + } + const login = await readClaudeOAuthCredentials({ + credentialsFileConfigDir: home, + keychainConfigDir: home + }) + return !login.token && !login.hasRefreshableCredentials && !login.unavailable +} diff --git a/src/main/rate-limits/providers/claude/claude-oauth-credentials.ts b/src/main/rate-limits/providers/claude/claude-oauth-credentials.ts index 5c6b11b11b3..727cee43b38 100644 --- a/src/main/rate-limits/providers/claude/claude-oauth-credentials.ts +++ b/src/main/rate-limits/providers/claude/claude-oauth-credentials.ts @@ -109,7 +109,7 @@ export async function readClaudeOAuthCredentials( return keychain } try { - return parseClaudeOAuthCredentialsJson( + const file = parseClaudeOAuthCredentialsJson( await readFile( path.join( options?.credentialsFileConfigDir ?? path.join(homedir(), '.claude'), @@ -119,6 +119,10 @@ export async function readClaudeOAuthCredentials( ), 'credentials-file' ) + // A file with no token must not hide a refreshable or unreadable Keychain login. + return file.token || !(keychain.hasRefreshableCredentials || keychain.unavailable) + ? file + : keychain } catch (error) { return isDefinitiveAbsence(error) ? keychain : { ...keychain, unavailable: true } }