mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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.
This commit is contained in:
@@ -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<object>()),
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user