fix(claude): match keychain account to Claude Code (#16673)

SSO $USER values like first@example.com fail Claude Code's
account charset, so login writes claude-code-user while Orca
looked up the email. Fixes stablyai/orca#12857.
This commit is contained in:
Rudimar Ronsoni
2026-09-02 23:08:56 -07:00
committed by GitHub
parent 94fcbe1908
commit e48d217670
5 changed files with 148 additions and 39 deletions
@@ -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 })
)
@@ -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
}
@@ -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<ClaudeCommandConfig> {
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
}
+63 -20
View File
@@ -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]
])
})
})
+63 -12
View File
@@ -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<string | null> {
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<void> {
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<void> {
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<string | null> {