mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user