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 } }