diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index 43e051a95fd..ec5572f5b3e 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -219,7 +219,7 @@ describe('ClaudeAccountService credential capture', () => { '--exec', 'bash', '-lc', - "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" + "export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; export CLAUDE_SECURESTORAGE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'" ], expect.objectContaining({ shell: false, windowsVerbatimArguments: false }) ) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index d3ef1f68113..63396f5a9de 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -188,6 +188,14 @@ type ClaudeSpawnConfig = { windowsVerbatimArguments: boolean } +function claudeConfigDirEnv(configDir: string): NodeJS.ProcessEnv { + return { + CLAUDE_CONFIG_DIR: configDir, + // Why: Claude Code 2.1.220+ hashes this for the Keychain service name. + CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir + } +} + function resolveClaudeInvocation( args: string[], configDir: ClaudeCommandConfig, @@ -200,7 +208,7 @@ function resolveClaudeInvocation( args: interactiveLogin.args, env: withCliRuntimeOnPath(hostClaudeCommand(), { ...process.env, - CLAUDE_CONFIG_DIR: configDir.windowsPath + ...claudeConfigDirEnv(configDir.windowsPath) }), windowsVerbatimArguments: false } @@ -213,7 +221,7 @@ function resolveClaudeInvocation( '--exec', 'bash', '-lc', - `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; export CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` ], env: process.env, windowsVerbatimArguments: false @@ -223,7 +231,7 @@ function resolveClaudeInvocation( ...buildWindowsCommandInvocation(hostClaudeCommand(), args), env: withCliRuntimeOnPath(hostClaudeCommand(), { ...process.env, - CLAUDE_CONFIG_DIR: configDir.windowsPath + ...claudeConfigDirEnv(configDir.windowsPath) }) } : { @@ -231,7 +239,7 @@ function resolveClaudeInvocation( args, env: withCliRuntimeOnPath(hostClaudeCommand(), { ...process.env, - CLAUDE_CONFIG_DIR: configDir.windowsPath + ...claudeConfigDirEnv(configDir.windowsPath) }), windowsVerbatimArguments: false } diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts index a02c07da415..ce9a398bb86 100644 --- a/src/main/claude-accounts/claude-login-session.ts +++ b/src/main/claude-accounts/claude-login-session.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { toWindowsWslPath } from '../wsl' @@ -96,8 +96,15 @@ async function createTemporaryClaudeConfigDir( location: ClaudeManagedAuthLocation ): Promise { if (location.managedAuthRuntime !== 'wsl') { + const created = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + let windowsPath = created + try { + windowsPath = realpathSync(created) + } catch { + // Keep the mkdtemp path if the temp root cannot be resolved. + } return { - windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), + windowsPath, linuxPath: null, wslDistro: null } diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index f0ec1dd40c5..5a3321200f7 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -15,6 +15,10 @@ vi.mock('node:child_process', () => ({ const execFileMock = vi.mocked(execFile) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +const originalUser = process.env.USER +const originalUsername = process.env.USERNAME +const TEST_USER = 'orca-test-user' +const SSO_USER = 'sso.user@example.com' function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -42,6 +46,8 @@ describe('Claude Keychain credentials', () => { beforeEach(() => { setPlatform('darwin') execFileMock.mockReset() + process.env.USER = TEST_USER + delete process.env.USERNAME }) afterEach(() => { @@ -49,6 +55,16 @@ describe('Claude Keychain credentials', () => { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } + if (originalUser === undefined) { + delete process.env.USER + } else { + process.env.USER = originalUser + } + if (originalUsername === undefined) { + delete process.env.USERNAME + } else { + process.env.USERNAME = originalUsername + } }) it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { @@ -69,7 +85,7 @@ describe('Claude Keychain credentials', () => { '-s', scopedService, '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w' ]) }) @@ -94,7 +110,7 @@ describe('Claude Keychain credentials', () => { '-s', 'Claude Code-credentials', '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w' ]) }) @@ -115,7 +131,7 @@ describe('Claude Keychain credentials', () => { '-s', scopedService, '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w', 'credentials-json' ]) @@ -138,7 +154,7 @@ describe('Claude Keychain credentials', () => { '-s', scopedService, '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w', 'credentials-json' ], @@ -148,7 +164,7 @@ describe('Claude Keychain credentials', () => { '-s', 'Claude Code-credentials', '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w', 'credentials-json' ] @@ -171,7 +187,7 @@ describe('Claude Keychain credentials', () => { '-s', scopedService, '-a', - process.env.USER || process.env.USERNAME || 'user', + TEST_USER, '-w' ]) }) @@ -217,20 +233,47 @@ describe('Claude Keychain credentials', () => { await deleteActiveClaudeKeychainCredentials(configDir) expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ - [ - 'delete-generic-password', - '-s', - scopedService, - '-a', - process.env.USER || process.env.USERNAME || 'user' - ], - [ - 'delete-generic-password', - '-s', - 'Claude Code-credentials', - '-a', - process.env.USER || process.env.USERNAME || 'user' - ] + ['delete-generic-password', '-s', scopedService, '-a', TEST_USER], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', TEST_USER] + ]) + }) + + it('looks up claude-code-user when $USER contains @ (#12857)', async () => { + process.env.USER = SSO_USER + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"ok"}}\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials()).resolves.toBe( + '{"claudeAiOauth":{"accessToken":"ok"}}' + ) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + 'Claude Code-credentials', + '-a', + 'claude-code-user', + '-w' + ]) + }) + + it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { + process.env.USER = SSO_USER + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await deleteActiveClaudeKeychainCredentials(configDir) + + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + ['delete-generic-password', '-s', scopedService, '-a', 'claude-code-user'], + ['delete-generic-password', '-s', scopedService, '-a', SSO_USER], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', 'claude-code-user'], + ['delete-generic-password', '-s', 'Claude Code-credentials', '-a', SSO_USER] ]) }) }) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 49d89c33d95..92c11e74650 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,5 +1,7 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { userInfo } from 'node:os' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' @@ -25,7 +27,18 @@ export async function readActiveClaudeKeychainCredentials( export async function readActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { - return readKeychainPassword(getActiveClaudeService(configDir), getKeychainUser()) + if (!configDir) { + return readKeychainPassword(getActiveClaudeService(), getKeychainUser()) + } + // Why: macOS tmp is /var → /private/var. Claude hashes the realpath; a + // mkdtemp login dir would miss the Keychain item if we only hashed the raw path. + for (const dir of claudeConfigDirKeychainAliases(configDir)) { + const credentials = await readKeychainPassword(getActiveClaudeService(dir), getKeychainUser()) + if (credentials) { + return credentials + } + } + return null } export async function writeActiveClaudeKeychainCredentials( @@ -49,16 +62,23 @@ export async function writeActiveClaudeKeychainCredentialsForRuntime( export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { for (const service of getActiveClaudeServices(configDir)) { - await deleteKeychainPassword(service, getKeychainUser()) + for (const account of getKeychainUsersForCleanup()) { + await deleteKeychainPassword(service, account) + } } } export async function deleteActiveClaudeKeychainCredentialsStrict( configDir?: string ): Promise { - await deleteKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), { - failOnAccessError: true - }) + const dirs = configDir ? claudeConfigDirKeychainAliases(configDir) : [undefined] + for (const dir of dirs) { + for (const account of getKeychainUsersForCleanup()) { + await deleteKeychainPassword(getActiveClaudeService(dir), account, { + failOnAccessError: true + }) + } + } } export async function readManagedClaudeKeychainCredentials( @@ -78,8 +98,25 @@ export async function deleteManagedClaudeKeychainCredentials(accountId: string): await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) } +const KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/ +const CLAUDE_CODE_FALLBACK_USER = 'claude-code-user' + function getKeychainUser(): string { - return process.env.USER || process.env.USERNAME || 'user' + // Why: Claude Code 2.1+ rejects $USER outside [a-zA-Z0-9._-] (SSO names like + // first@example.com) and stores the item under claude-code-user (#12857). + let user: string + try { + user = process.env.USER || process.env.USERNAME || userInfo().username + } catch { + return CLAUDE_CODE_FALLBACK_USER + } + return KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_CODE_FALLBACK_USER +} + +function getKeychainUsersForCleanup(): string[] { + const derived = getKeychainUser() + const raw = process.env.USER || process.env.USERNAME + return raw && raw !== derived ? [derived, raw] : [derived] } function getActiveClaudeService(configDir?: string): string { @@ -87,16 +124,30 @@ function getActiveClaudeService(configDir?: string): string { return ACTIVE_CLAUDE_SERVICE } // Why: Claude Code 2.1+ scopes macOS Keychain credentials by config dir - // using the first 8 hex chars of sha256(CLAUDE_CONFIG_DIR). - const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + // using the first 8 hex chars of sha256(NFC(CLAUDE_CONFIG_DIR)). + const suffix = createHash('sha256').update(configDir.normalize('NFC')).digest('hex').slice(0, 8) return `${ACTIVE_CLAUDE_SERVICE}-${suffix}` } +export function claudeConfigDirKeychainAliases(configDir: string): string[] { + const aliases = [configDir] + try { + const canonical = realpathSync(configDir) + if (canonical !== configDir) { + aliases.push(canonical) + } + } catch { + // Login temp dirs can vanish before capture; keep the raw path. + } + return aliases +} + function getActiveClaudeServices(configDir?: string): string[] { - const scopedService = getActiveClaudeService(configDir) - return scopedService === ACTIVE_CLAUDE_SERVICE - ? [ACTIVE_CLAUDE_SERVICE] - : [scopedService, ACTIVE_CLAUDE_SERVICE] + if (!configDir) { + return [ACTIVE_CLAUDE_SERVICE] + } + const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) + return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] } async function readKeychainPassword(service: string, account: string): Promise {