fix(claude-accounts): preserve shared MCP OAuth credentials across an account switch (#21931)

* fix(claude-accounts): preserve shared MCP OAuth credentials across an account switch

Orca's account switch writes the target managed account's own stored credential
verbatim to the global "Claude Code-credentials" Keychain item. That credential
never carries mcpOAuth/mcpOAuthClientConfig/mcpXaaIdp/mcpXaaIdpConfig/pluginSecrets
(they are machine-shared MCP connector state, not per-account), so every switch
silently drops any MCP connections the previous session had.

Merge the live credential's copy of these shared fields into the target credential
right before the Keychain write, live-wins (absence included), mirroring how the
third-party claude-swap tool already treats this exact shared Keychain item.

Fixes #16098

* fix(claude-accounts): skip reformatting shared credential merge when nothing changed

mergeSharedClaudeCredentialFields always re-serialized via JSON.stringify, even when
the shared-key set was identical on both sides. The reformatted-but-semantically-equal
JSON (e.g. missing the original trailing newline) then read as an external Claude Code
refresh to the read-back byte-equality check in runtime-auth-sync, causing every
subsequent sync to wrongly adopt it into managed credential storage.

Return the original string when the merge changes nothing.

* fix(claude-accounts): validate OAuth shape, preserve key order, and degrade Keychain read failures

Address CodeRabbit/pullfrog follow-up review on the shared-credential merge:

- parseCredentialObject accepted claudeAiOauth as null or a primitive; the merge would
  then serialize the malformed target instead of passing it through unchanged. Validate
  it is a non-null object (hasClaudeOauthObject) before merging.
- The no-op guard compared whole-object JSON.stringify output, so an existing shared key
  interleaved among target-only keys got moved to the end of the rebuilt object even when
  its value did not change, producing a formatting-only rewrite. Compare per shared key
  and keep the target's own key order via object spread instead.
- runtime-auth-sync read the live Keychain credential for the merge with the raw
  (non-best-effort) function, so a transient Keychain read error aborted the whole
  account switch instead of just skipping the merge. Use the inherited
  readAggregateClaudeKeychainCredentialsBestEffort, matching every other Keychain read
  in this subsystem.
- Fixed a dead citation URL (scaryghost/claude-swap 404s; the real repo is
  realiti4/claude-swap, verified to contain the cited SHARED_CREDENTIAL_KEYS /
  merge_shared_credential_fields).
- Added docstrings to every function touched by this change.

Tests: 5 new cases (null/primitive claudeAiOauth, interleaved shared key no-op and
update, Keychain read failure does not abort the switch).

* fix(claude-accounts): preserve connector state across all runtime credential writes

Keep live MCP grants in both runtime files and keychain items, excluding shared secrets from managed account capture and refresh read-back. Preserve rotations and revocations through default restoration and reject unreadable live state before destructive writes.

* fix(claude-accounts): retain grants from all live stores on startup

Before Orca has a last-written baseline, a missing shared field in one store cannot prove revocation. Carry present fields from the other live stores, prioritizing a proved newer refresh candidate when available.

* fix(claude-accounts): reconcile connector updates without guessing token freshness

* test(preflight): freeze clock for Windows PATH probe assertion

---------

Co-authored-by: tomarai85 <tomarai85@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Tomonori Arai
2026-10-05 19:49:32 -07:00
committed by GitHub
co-authored by tomarai85 Neil
parent d73efccc7d
commit 1b52be6255
19 changed files with 1048 additions and 35 deletions
@@ -3,6 +3,7 @@ import { join, relative, resolve, sep } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import { runWslProcess } from '../wsl/wsl-runner'
import { stripSharedClaudeCredentialFields } from './shared-credential-fields'
import {
getClaudeManagedAccountsRoot,
readClaudeManagedAuthFile,
@@ -74,6 +75,9 @@ export class ClaudeManagedAuthStorage {
credentialsJson: string
): Promise<void> {
const trustedPath = await this.assertOwned(managedAuthPath, accountId)
if (!parseWslUncPath(trustedPath)) {
credentialsJson = stripSharedClaudeCredentialFields(credentialsJson)
}
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(accountId, credentialsJson)
} else {
@@ -41,7 +41,7 @@ describe('ClaudeRuntimeAuthService', () => {
cleanupRuntimeAuthTestState()
})
it('reads back refreshed file credentials when keychain reads fail', async () => {
it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed')
@@ -64,10 +64,12 @@ describe('ClaudeRuntimeAuthService', () => {
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
testState.throwScopedKeychainRead = true
testState.throwLegacyKeychainRead = true
await service.syncForCurrentSelection()
await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed')
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
expect(testState.scopedKeychainCredentials).toBe(originalCredentials)
expect(testState.legacyKeychainCredentials).toBe(originalCredentials)
warn.mockRestore()
})
@@ -421,7 +421,7 @@ describe('ClaudeRuntimeAuthService', () => {
}
})
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => {
if (hostPlatform === 'win32') {
return
}
@@ -449,7 +449,7 @@ describe('ClaudeRuntimeAuthService', () => {
writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8')
chmodSync(runtimeCredentialsPath, 0o000)
try {
await service.syncForCurrentSelection()
await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' })
} finally {
if (existsSync(runtimeCredentialsPath)) {
chmodSync(runtimeCredentialsPath, 0o600)
@@ -457,7 +457,8 @@ describe('ClaudeRuntimeAuthService', () => {
warn.mockRestore()
}
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(rotatedCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials)
expect(testState.scopedKeychainCredentials).toBe(managedCredentials)
})
it('tightens credential file permissions when unchanged content is already present', async () => {
@@ -0,0 +1,85 @@
import {
cleanupRuntimeAuthTestState,
createElectronMock,
createKeychainMock,
createOauthRefreshMock,
resetRuntimeAuthTestState,
testState
} from './runtime-auth-service-test-harness'
import {
createSharedCredentialRuntime,
sharedFields,
withSharedFields
} from './runtime-auth-shared-credentials-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync, writeFileSync } from 'node:fs'
vi.mock('electron', () => createElectronMock())
vi.mock('./oauth-refresh', () => createOauthRefreshMock())
vi.mock('./keychain', () => createKeychainMock())
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: () => testState.fakeHomeDir }
})
describe('shared connector credential write failures', () => {
beforeEach(resetRuntimeAuthTestState)
afterEach(cleanupRuntimeAuthTestState)
it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => {
const { service, settings, runtimePath, first } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const rotated = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } }
}
testState.scopedKeychainCredentials = withSharedFields(first, rotated)
settings.activeClaudeManagedAccountId = 'second'
testState.throwLegacyRuntimeKeychainWrite = true
await expect(service.syncForCurrentSelection()).rejects.toThrow(
'legacy runtime keychain write failed'
)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated)
testState.throwLegacyRuntimeKeychainWrite = false
settings.activeClaudeManagedAccountId = 'first'
await service.forceMaterializeCurrentSelectionForRollback()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first')
expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8'))
expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials)
})
it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => {
const { service, settings, runtimePath, system } = await createSharedCredentialRuntime()
const figma = sharedFields.mcpOAuth.figma
testState.scopedKeychainCredentials = withSharedFields(system, {
...sharedFields,
mcpOAuth: { figma }
})
testState.legacyKeychainCredentials = withSharedFields(system, {
...sharedFields,
mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } }
})
writeFileSync(runtimePath, system)
settings.activeClaudeManagedAccountId = 'first'
testState.throwLegacyRuntimeKeychainWrite = true
await expect(service.syncForCurrentSelection()).rejects.toThrow(
'legacy runtime keychain write failed'
)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({
figma,
notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' }
})
testState.throwLegacyRuntimeKeychainWrite = false
await service.syncForCurrentSelection()
const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8'))
expect(runtime.mcpOAuth).toEqual({
figma,
notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' }
})
expect(runtime.claudeAiOauth.accessToken).toBe('first')
expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8'))
expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials)
})
})
@@ -0,0 +1,292 @@
import {
cleanupRuntimeAuthTestState,
createClaudeCredentialsJson,
createElectronMock,
createKeychainMock,
createOauthRefreshMock,
createStore,
readManagedCredentialsForTest,
resetRuntimeAuthTestState,
testState
} from './runtime-auth-service-test-harness'
import {
createSharedCredentialRuntime,
sharedFields,
withSharedFields
} from './runtime-auth-shared-credentials-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
vi.mock('electron', () => createElectronMock())
vi.mock('./oauth-refresh', () => createOauthRefreshMock())
vi.mock('./keychain', () => createKeychainMock())
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: () => testState.fakeHomeDir }
})
describe('shared Claude connector credentials', () => {
beforeEach(resetRuntimeAuthTestState)
afterEach(cleanupRuntimeAuthTestState)
it.each(['scoped', 'legacy', 'file'] as const)(
'preserves connector grants stored only in %s when there is no previous Orca write',
async (surface) => {
const { service, settings, runtimePath, system } = await createSharedCredentialRuntime()
testState.scopedKeychainCredentials = system
testState.legacyKeychainCredentials = system
writeFileSync(runtimePath, system)
if (surface === 'scoped') {
testState.scopedKeychainCredentials = withSharedFields(system)
} else if (surface === 'legacy') {
testState.legacyKeychainCredentials = withSharedFields(system)
} else {
writeFileSync(runtimePath, withSharedFields(system))
}
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields)
}
)
it.each(['darwin', 'linux', 'win32'] as const)(
'excludes connector secrets when capturing a managed account on %s',
async (platform) => {
const { firstPath, first } = await createSharedCredentialRuntime(platform)
const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage')
await new ClaudeManagedAuthStorage().writeCredentials(
'first',
firstPath,
withSharedFields(first)
)
expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual(
JSON.parse(first)
)
}
)
it.each(['scoped', 'legacy', 'file'] as const)(
'propagates connector revocations from the %s surface and does not resurrect frozen account grants',
async (surface) => {
const { service, settings, runtimePath, first, secondPath, second } =
await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
if (surface === 'scoped') {
testState.scopedKeychainCredentials = first
} else if (surface === 'legacy') {
testState.legacyKeychainCredentials = first
} else {
writeFileSync(runtimePath, first)
}
testState.managedKeychainCredentials.set('second', withSharedFields(second))
writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second))
settings.activeClaudeManagedAccountId = 'second'
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second))
expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second))
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second))
}
)
it('preserves grants refreshed only in the keychain when returning to the system default', async () => {
const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const rotated = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } }
}
testState.legacyKeychainCredentials = withSharedFields(first, rotated)
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated)
expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated)
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated)
const nextRotation = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } }
}
testState.scopedKeychainCredentials = withSharedFields(system, nextRotation)
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation)
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation)
})
it.each(['darwin', 'linux', 'win32'] as const)(
'keeps connector grants through account switches, syncs, restart, and deselect on %s',
async (platform) => {
const state = await createSharedCredentialRuntime(platform)
const { service, settings, runtimePath, first, second, firstPath, secondPath } = state
for (const id of ['first', 'second', 'first']) {
settings.activeClaudeManagedAccountId = id
await service.syncForCurrentSelection()
await service.syncForCurrentSelection()
const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8'))
expect(runtime).toMatchObject(sharedFields)
expect(runtime.claudeAiOauth.accessToken).toBe(id)
if (platform === 'darwin') {
expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8'))
expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials)
}
}
expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual(
JSON.parse(first)
)
expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual(
JSON.parse(second)
)
const rotated = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } }
}
const live = withSharedFields(first, rotated)
writeFileSync(runtimePath, live)
testState.scopedKeychainCredentials = live
testState.legacyKeychainCredentials = live
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock.
const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never)
await restarted.syncForCurrentSelection()
settings.activeClaudeManagedAccountId = null
await restarted.syncForCurrentSelection()
const restored = JSON.parse(readFileSync(runtimePath, 'utf-8'))
expect(restored).toMatchObject(rotated)
expect(restored.claudeAiOauth.accessToken).toBe('system')
if (platform === 'darwin') {
expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated)
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated)
}
}
)
it.each(['scoped', 'legacy', 'file'] as const)(
'preserves MCP rotations written only to the %s surface while adopting a Claude refresh',
async (surface) => {
const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const refreshed = createClaudeCredentialsJson(
'first@example.com',
'refreshed',
null,
Date.now() + 120_000
)
const rotated = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } }
}
const live = withSharedFields(refreshed, rotated)
if (surface === 'scoped') {
testState.scopedKeychainCredentials = live
}
if (surface === 'legacy') {
testState.legacyKeychainCredentials = live
}
if (surface === 'file') {
writeFileSync(runtimePath, live)
}
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated)
expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual(
JSON.parse(refreshed)
)
}
)
it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => {
const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const refreshed = createClaudeCredentialsJson(
'first@example.com',
'refreshed',
null,
Date.now() + 120_000
)
const rotated = {
...sharedFields,
mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } }
}
testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock.
const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never)
await expect(restarted.syncForCurrentSelection()).rejects.toThrow(
'live connector credentials conflict'
)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields)
expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields)
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated)
expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual(
JSON.parse(refreshed)
)
})
it('leaves all credentials untouched when the active keychain cannot be read', async () => {
const { service, settings, runtimePath } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const before = readFileSync(runtimePath, 'utf-8')
settings.activeClaudeManagedAccountId = 'second'
testState.throwScopedKeychainRead = true
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed')
expect(readFileSync(runtimePath, 'utf-8')).toBe(before)
expect(testState.scopedKeychainCredentials).toBe(before)
expect(testState.legacyKeychainCredentials).toBe(before)
testState.throwScopedKeychainRead = false
await service.syncForCurrentSelection()
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second')
warn.mockRestore()
})
it.each(['scoped', 'legacy', 'file'] as const)(
'refuses to overwrite malformed live credentials in the %s surface',
async (surface) => {
const { service, settings, runtimePath } = await createSharedCredentialRuntime()
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
if (surface === 'scoped') {
testState.scopedKeychainCredentials = '{broken'
} else if (surface === 'legacy') {
testState.legacyKeychainCredentials = '{broken'
} else {
writeFileSync(runtimePath, '{broken')
}
const fileBefore = readFileSync(runtimePath, 'utf-8')
const scopedBefore = testState.scopedKeychainCredentials
const legacyBefore = testState.legacyKeychainCredentials
settings.activeClaudeManagedAccountId = 'second'
await expect(service.syncForCurrentSelection()).rejects.toThrow(
'Cannot preserve malformed Claude runtime credentials'
)
expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore)
expect(testState.scopedKeychainCredentials).toBe(scopedBefore)
expect(testState.legacyKeychainCredentials).toBe(legacyBefore)
}
)
it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => {
const { service, settings, runtimePath, first } = await createSharedCredentialRuntime()
// A missing system credential is a signed-out default, with no connector grants yet.
rmSync(runtimePath)
testState.scopedKeychainCredentials = null
testState.legacyKeychainCredentials = null
settings.activeClaudeManagedAccountId = 'first'
await service.syncForCurrentSelection()
const live = withSharedFields(first)
writeFileSync(runtimePath, live)
testState.scopedKeychainCredentials = live
testState.legacyKeychainCredentials = live
settings.activeClaudeManagedAccountId = null
await service.syncForCurrentSelection()
expect(existsSync(runtimePath)).toBe(true)
expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields)
expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields)
expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields)
})
})
@@ -0,0 +1,51 @@
import {
createClaudeAccount,
createClaudeCredentialsJson,
createManagedClaudeAuth,
createSettings,
createStore,
setPlatform,
testState
} from './runtime-auth-service-test-harness'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
export const sharedFields = {
mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } },
mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } },
mcpXaaIdp: { token: 'idp-token' },
mcpXaaIdpConfig: { issuer: 'idp-issuer' },
pluginSecrets: { plugin: 'secret' }
}
export function withSharedFields(
credentials: string,
fields: Record<string, unknown> = sharedFields
): string {
return JSON.stringify({ ...JSON.parse(credentials), ...fields })
}
export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') {
setPlatform(platform)
const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const system = createClaudeCredentialsJson('system@example.com', 'system')
const first = createClaudeCredentialsJson('first@example.com', 'first')
const second = createClaudeCredentialsJson('second@example.com', 'second')
const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first)
const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second)
writeFileSync(runtimePath, withSharedFields(system))
testState.scopedKeychainCredentials = withSharedFields(system)
testState.legacyKeychainCredentials = withSharedFields(system)
const settings = createSettings({
claudeManagedAccounts: [
createClaudeAccount('first', firstPath, { email: 'first@example.com' }),
createClaudeAccount('second', secondPath, { email: 'second@example.com' })
]
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock.
const service = new ClaudeRuntimeAuthService(store as never)
await service.syncForCurrentSelection()
return { service, settings, runtimePath, first, second, system, firstPath, secondPath }
}
@@ -1,4 +1,5 @@
import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage'
import { stripSharedClaudeCredentialFields } from '../shared-credential-fields'
import type {
ClaudeAuthIdentity,
ClaudeReadBackMatch,
@@ -6,6 +7,26 @@ import type {
} from './runtime-auth-types'
export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage {
protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean {
if (left === right) {
return true
}
if (left === null || right === null) {
return false
}
try {
const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left)))
const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right)))
return (
leftAccount !== null &&
rightAccount !== null &&
this.jsonValuesEqual(leftAccount, rightAccount)
)
} catch {
return false
}
}
protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null {
let parsed: Record<string, unknown>
try {
@@ -41,7 +41,11 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged
service: 'scoped' | 'legacy',
managedCredentialsJson: string | undefined
): string | null {
if (managedCredentialsJson && credentialsJson === managedCredentialsJson && previousSnapshot) {
if (
managedCredentialsJson &&
this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) &&
previousSnapshot
) {
const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service)
if (previousValue.status === 'captured') {
return previousValue.credentialsJson
@@ -14,6 +14,7 @@ import {
writeManagedClaudeKeychainCredentials
} from '../keychain'
import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity'
import { stripSharedClaudeCredentialFields } from '../shared-credential-fields'
const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout'
@@ -28,9 +29,13 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden
return null
}
if (process.platform === 'darwin') {
return readManagedClaudeKeychainCredentials(account.id)
const credentials = await readManagedClaudeKeychainCredentials(account.id)
return credentials === null ? null : stripSharedClaudeCredentialFields(credentials)
}
return readClaudeManagedAuthFile(managedAuthPath, '.credentials.json')
const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json')
return credentials === null || account.managedAuthRuntime === 'wsl'
? credentials
: stripSharedClaudeCredentialFields(credentials)
}
protected async writeManagedCredentials(
@@ -41,6 +46,9 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden
if (!managedAuthPath) {
throw new Error('Managed Claude auth storage is not owned by Orca.')
}
if (account.managedAuthRuntime !== 'wsl') {
credentialsJson = stripSharedClaudeCredentialFields(credentialsJson)
}
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, credentialsJson)
return
@@ -22,7 +22,11 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi
this.lastWrittenCredentialsJson === null
? candidates
: candidates.filter(
(candidate) => candidate.credentialsJson !== this.lastWrittenCredentialsJson
(candidate) =>
!this.accountCredentialFieldsEqual(
candidate.credentialsJson,
this.lastWrittenCredentialsJson
)
)
if (changedCandidates.length === 0) {
return { status: 'unchanged' }
@@ -97,12 +101,13 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi
await this.writeManagedCredentials(match.account, runtimeContents)
if (options.updateLastWrittenCredentialsJson) {
this.writeRuntimeCredentials(runtimeContents)
this.lastWrittenCredentialsJson = runtimeContents
const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents)
this.writeRuntimeCredentials(merged)
if (process.platform === 'darwin') {
const paths = this.pathResolver.getRuntimePaths()
await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir)
await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir)
}
this.lastWrittenSharedCredentialsJson = merged
}
return { status: 'persisted' }
} catch (error) {
@@ -145,7 +150,8 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi
pushCandidate(legacyKeychainCredentials)
pushCandidate(fileCredentials)
return candidates.filter(
(candidate) => candidate.credentialsJson !== baselineCredentialsJson
(candidate) =>
!this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson)
)
}
pushCandidate(scopedKeychainCredentials)
@@ -2,8 +2,13 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
import {
deleteActiveClaudeKeychainCredentialsStrict,
readActiveClaudeKeychainCredentialsStrict,
writeActiveClaudeKeychainCredentials
} from '../keychain'
import {
mergeSharedClaudeCredentialFields,
reconcileSharedClaudeCredentialFields
} from '../shared-credential-fields'
import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots'
import {
RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR,
@@ -11,6 +16,31 @@ import {
} from './runtime-auth-types'
export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots {
protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise<string> {
const paths = this.pathResolver.getRuntimePaths()
const candidates: string[] = []
if (process.platform === 'darwin') {
// A failed read must stop the switch before any shared tokens are overwritten.
const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir)
const legacy = await readActiveClaudeKeychainCredentialsStrict()
if (scoped !== null) {
candidates.push(scoped)
}
if (legacy !== null) {
candidates.push(legacy)
}
}
const file = this.readRuntimeCredentialsFile()
if (file !== null) {
candidates.push(file)
}
const shared = reconcileSharedClaudeCredentialFields(
candidates,
this.lastWrittenSharedCredentialsJson
)
return mergeSharedClaudeCredentialFields(credentialsJson, shared)
}
protected readRuntimeCredentialsFile(): string | null {
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null
@@ -58,7 +88,10 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap
const currentCredentialsJson = existsSync(paths.credentialsPath)
? readFileSync(paths.credentialsPath, 'utf-8')
: null
return currentCredentialsJson === previouslyWrittenCredentialsJson
return this.accountCredentialFieldsEqual(
currentCredentialsJson,
previouslyWrittenCredentialsJson
)
}
protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean {
@@ -76,10 +109,17 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap
}
}
protected restoreRuntimeCredentials(credentialsJson: string | null): void {
protected restoreRuntimeCredentials(
credentialsJson: string | null,
sharedCredentialsJson?: string
): void {
const paths = this.pathResolver.getRuntimePaths()
if (credentialsJson !== null) {
this.writeRuntimeCredentials(credentialsJson)
const restored = mergeSharedClaudeCredentialFields(
credentialsJson ?? '{}',
sharedCredentialsJson ?? this.readRuntimeCredentialsFile()
)
if (credentialsJson !== null || restored !== '{}') {
this.writeRuntimeCredentials(restored)
} else {
rmSync(paths.credentialsPath, { force: true })
}
@@ -122,16 +162,20 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap
await this.readActiveClaudeKeychainCredentialsBestEffort(configDir)
return (
previouslyWrittenCredentialsJson !== null &&
currentCredentialsJson === previouslyWrittenCredentialsJson
this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson)
)
}
protected async restoreActiveClaudeKeychainCredentials(
credentialsJson: string | null,
configDir?: string
configDir?: string,
sharedCredentialsJson?: string
): Promise<void> {
await (credentialsJson !== null
? writeActiveClaudeKeychainCredentials(credentialsJson, configDir)
const live =
sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir))
const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live)
await (credentialsJson !== null || restored !== '{}'
? writeActiveClaudeKeychainCredentials(restored, configDir)
: deleteActiveClaudeKeychainCredentialsStrict(configDir))
}
@@ -12,7 +12,7 @@ export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback
): Promise<void> {
const snapshotPath = this.getSystemDefaultSnapshotPath()
const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath)
if (runtimeCredentialsJson !== managedCredentialsJson) {
if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) {
await this.captureSystemDefaultSnapshot({
force: true,
previousSnapshot: existingSnapshot,
@@ -1,6 +1,4 @@
import { rmSync } from 'node:fs'
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
import { deleteActiveClaudeKeychainCredentialsStrict } from '../keychain'
import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture'
import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types'
@@ -40,25 +38,39 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
hasCredentialSurfaceOwnership =
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
}
const sharedCredentialsJson = hasCredentialSurfaceOwnership
? await this.mergeLiveRuntimeSharedCredentials('{}')
: undefined
this.restoreRuntimeOauthAccountIfOwned(
snapshot?.configOauthAccount ?? null,
this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership),
{ allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership }
)
if (fileCredentialsOwned) {
this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null)
this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson)
}
if (process.platform === 'darwin') {
if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) {
await this.restoreActiveClaudeKeychainCredentials(
scopedSnapshot.credentialsJson,
paths.configDir
paths.configDir,
sharedCredentialsJson
)
}
if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) {
await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson)
await this.restoreActiveClaudeKeychainCredentials(
legacySnapshot.credentialsJson,
undefined,
sharedCredentialsJson
)
}
}
this.recordRestoredSharedCredentials(
sharedCredentialsJson,
fileCredentialsOwned,
scopedSnapshot?.status === 'captured' && scopedKeychainOwned,
legacySnapshot?.status === 'captured' && legacyKeychainOwned
)
this.lastWrittenCredentialsJson = null
this.lastWrittenOauthAccount = null
this.hasLastWrittenOauthAccount = false
@@ -79,6 +91,21 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
return null
}
private recordRestoredSharedCredentials(
credentialsJson: string | undefined,
fileRestored: boolean,
scopedRestored: boolean,
legacyRestored: boolean
): void {
if (
credentialsJson !== undefined &&
fileRestored &&
(process.platform !== 'darwin' || (scopedRestored && legacyRestored))
) {
this.lastWrittenSharedCredentialsJson = credentialsJson
}
}
protected async clearRuntimeAuthForAccount(
account: ClaudeManagedAccount,
managedOauthAccount: unknown
@@ -104,6 +131,9 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
}
const hasCredentialSurfaceOwnership =
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
const sharedCredentialsJson = hasCredentialSurfaceOwnership
? await this.mergeLiveRuntimeSharedCredentials('{}')
: undefined
this.restoreRuntimeOauthAccountIfOwned(
null,
this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership),
@@ -112,16 +142,26 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
}
)
if (fileCredentialsOwned) {
rmSync(paths.credentialsPath, { force: true })
this.restoreRuntimeCredentials(null, sharedCredentialsJson)
}
if (process.platform === 'darwin') {
if (scopedKeychainOwned) {
await deleteActiveClaudeKeychainCredentialsStrict(paths.configDir)
await this.restoreActiveClaudeKeychainCredentials(
null,
paths.configDir,
sharedCredentialsJson
)
}
if (legacyKeychainOwned) {
await deleteActiveClaudeKeychainCredentialsStrict()
await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson)
}
}
this.recordRestoredSharedCredentials(
sharedCredentialsJson,
fileCredentialsOwned,
scopedKeychainOwned,
legacyKeychainOwned
)
}
protected async restoreSystemDefaultSnapshotForMissingManagedCredentials(
@@ -159,6 +199,9 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
}
const hasCredentialSurfaceOwnership =
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
const sharedCredentialsJson = hasCredentialSurfaceOwnership
? await this.mergeLiveRuntimeSharedCredentials('{}')
: undefined
this.restoreRuntimeOauthAccountIfOwned(
snapshot.configOauthAccount,
this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership),
@@ -167,19 +210,30 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC
}
)
if (fileCredentialsOwned) {
this.restoreRuntimeCredentials(snapshot.credentialsJson)
this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson)
}
if (process.platform === 'darwin') {
if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) {
await this.restoreActiveClaudeKeychainCredentials(
scopedSnapshot.credentialsJson,
paths.configDir
paths.configDir,
sharedCredentialsJson
)
}
if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) {
await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson)
await this.restoreActiveClaudeKeychainCredentials(
legacySnapshot.credentialsJson,
undefined,
sharedCredentialsJson
)
}
}
this.recordRestoredSharedCredentials(
sharedCredentialsJson,
fileCredentialsOwned,
scopedSnapshot?.status === 'captured' && scopedKeychainOwned,
legacySnapshot?.status === 'captured' && legacyKeychainOwned
)
this.clearLastWrittenRuntimeState()
}
}
@@ -7,6 +7,8 @@ export class ClaudeRuntimeAuthState {
protected lastSyncedAccountId: string | null = null
// Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default.
protected lastWrittenCredentialsJson: string | null = null
// Connector revocations require a baseline that reached every runtime store, not a partial file write.
protected lastWrittenSharedCredentialsJson: string | null = null
protected hasMaterializedRuntimeAuth = false
protected hasLastWrittenOauthAccount = false
protected lastWrittenOauthAccount: unknown = null
@@ -257,11 +257,15 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
}
const paths = this.pathResolver.getRuntimePaths()
this.writeRuntimeCredentials(credentialsJson)
const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson)
this.writeRuntimeCredentials(runtimeCredentialsJson)
if (process.platform === 'darwin') {
// Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both.
try {
await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir)
await writeActiveClaudeKeychainCredentialsForRuntime(
runtimeCredentialsJson,
paths.configDir
)
} catch (error) {
await this.restoreSystemDefaultSnapshot(
credentialsJson,
@@ -270,6 +274,7 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
throw error
}
}
this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson
const managedOauthAccount = await this.readManagedOauthAccount(activeAccount)
if (this.writeRuntimeOauthAccount(managedOauthAccount)) {
this.lastWrittenOauthAccount = managedOauthAccount
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import {
mergeSharedClaudeCredentialFields,
SHARED_CLAUDE_CREDENTIAL_KEYS
} from './shared-credential-fields'
describe('mergeSharedClaudeCredentialFields', () => {
it('merges the live credential shared fields into the target credential', () => {
const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } })
const live = JSON.stringify({
claudeAiOauth: { accessToken: 'live-token' },
mcpOAuth: { conn1: 'v1' },
pluginSecrets: { s: 1 }
})
const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live))
expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' })
expect(result.mcpOAuth).toEqual({ conn1: 'v1' })
expect(result.pluginSecrets).toEqual({ s: 1 })
})
it('is absence-authoritative: a shared key missing on live is not carried from the target', () => {
const target = JSON.stringify({
claudeAiOauth: { accessToken: 'target-token' },
mcpOAuth: { stale: 'rotated-out' }
})
const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } })
const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live))
expect(result.mcpOAuth).toBeUndefined()
})
it('leaves account-scoped sibling keys on the target untouched', () => {
const target = JSON.stringify({
claudeAiOauth: { accessToken: 'target-token' },
trustedDeviceToken: 'target-device-token'
})
const live = JSON.stringify({
claudeAiOauth: { accessToken: 'live-token' },
mcpOAuth: { conn1: 'v1' }
})
const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live))
expect(result.trustedDeviceToken).toBe('target-device-token')
expect(result.mcpOAuth).toEqual({ conn1: 'v1' })
})
it('returns the target unchanged when there is no live credential to merge from', () => {
const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } })
expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target)
})
it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => {
const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } })
expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target)
})
it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => {
const target = 'sk-ant-api-a-raw-managed-key'
const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } })
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('returns the target unchanged when the target JSON is malformed', () => {
const target = '{not valid json'
const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } })
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('returns the target unchanged when claudeAiOauth is null rather than an object', () => {
const target = JSON.stringify({ claudeAiOauth: null })
const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } })
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => {
const target = JSON.stringify({ claudeAiOauth: 'not-an-object' })
const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } })
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => {
// Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream.
const target = `${JSON.stringify({
claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' }
})}\n`
const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } })
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('covers every documented shared key, not just mcpOAuth', () => {
// Why: pins the actual key names, so removing one from the constant fails this test.
expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([
'mcpOAuth',
'mcpOAuthClientConfig',
'mcpXaaIdp',
'mcpXaaIdpConfig',
'pluginSecrets'
])
const target = JSON.stringify({ claudeAiOauth: {} })
const liveObj: Record<string, unknown> = { claudeAiOauth: {} }
for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) {
liveObj[key] = { present: true }
}
const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj)))
for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) {
expect(result[key]).toEqual({ present: true })
}
})
it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => {
// Why: rebuilding via key-order iteration used to move an existing shared key to the
// end even when its value did not change, causing a spurious formatting-only rewrite.
const target = `${JSON.stringify({
claudeAiOauth: { accessToken: 'target-token' },
mcpOAuth: { conn1: 'v1' },
trustedDeviceToken: 'target-device-token'
})}\n`
const live = JSON.stringify({
claudeAiOauth: { accessToken: 'live-token' },
mcpOAuth: { conn1: 'v1' }
})
expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target)
})
it('still updates an interleaved shared key in place when its live value actually differs', () => {
const target = JSON.stringify({
claudeAiOauth: { accessToken: 'target-token' },
mcpOAuth: { conn1: 'stale' },
trustedDeviceToken: 'target-device-token'
})
const live = JSON.stringify({
claudeAiOauth: { accessToken: 'live-token' },
mcpOAuth: { conn1: 'fresh' }
})
const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live))
expect(result.mcpOAuth).toEqual({ conn1: 'fresh' })
expect(result.trustedDeviceToken).toBe('target-device-token')
})
})
@@ -0,0 +1,166 @@
import { isDeepStrictEqual } from 'node:util'
export const SHARED_CLAUDE_CREDENTIAL_KEYS = [
'mcpOAuth',
'mcpOAuthClientConfig',
'mcpXaaIdp',
'mcpXaaIdpConfig',
'pluginSecrets'
] as const
function parseCredentialObject(credentialsJson: string | null): Record<string, unknown> | null {
if (!credentialsJson) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(credentialsJson)
} catch {
return null
}
return isCredentialObject(parsed) ? parsed : null
}
function isCredentialObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function stripSharedClaudeCredentialFields(credentialsJson: string): string {
const credential = parseCredentialObject(credentialsJson)
if (!credential) {
return credentialsJson
}
let changed = false
for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) {
if (Object.hasOwn(credential, key)) {
delete credential[key]
changed = true
}
}
return changed ? JSON.stringify(credential) : credentialsJson
}
// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots.
export function mergeSharedClaudeCredentialFields(
targetCredentialsJson: string,
liveCredentialsJson: string | null
): string {
const target = parseCredentialObject(targetCredentialsJson)
const live = parseCredentialObject(liveCredentialsJson)
if (
!target ||
!live ||
(Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth))
) {
return targetCredentialsJson
}
let changed = false
const merged: Record<string, unknown> = { ...target }
for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) {
const targetHasKey = Object.hasOwn(target, key)
if (Object.hasOwn(live, key)) {
if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) {
merged[key] = live[key]
changed = true
}
} else if (targetHasKey) {
delete merged[key]
changed = true
}
}
return changed ? JSON.stringify(merged) : targetCredentialsJson
}
type CredentialField = { present: boolean; value: unknown }
function credentialField(record: Record<string, unknown>, key: string): CredentialField {
const present = Object.hasOwn(record, key)
return { present, value: present ? record[key] : undefined }
}
function resolveCredentialField(
candidates: CredentialField[],
baseline: CredentialField | null
): CredentialField {
const changes = candidates.filter((candidate) =>
baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline)
)
const first = changes[0]
if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) {
throw new Error(
'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved'
)
}
return first ?? baseline ?? { present: false, value: undefined }
}
function resolveServerGrants(
sources: Record<string, unknown>[],
baseline: Record<string, unknown> | null,
key: string
): CredentialField | null {
const fields = sources.map((source) => credentialField(source, key))
const previous = baseline === null ? null : credentialField(baseline, key)
if (
fields.some((field) => field.present && !isCredentialObject(field.value)) ||
(previous?.present && !isCredentialObject(previous.value))
) {
return null
}
const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {}))
const previousMap =
previous === null ? null : isCredentialObject(previous.value) ? previous.value : {}
const names = new Set([
...maps.flatMap((map) => Object.keys(map)),
...Object.keys(previousMap ?? {})
])
const entries: [string, unknown][] = []
for (const name of names) {
// Keep a server's access/refresh token pair atomic while combining independent server updates.
const grant = resolveCredentialField(
maps.map((map) => credentialField(map, name)),
previousMap === null ? null : credentialField(previousMap, name)
)
if (grant.present) {
entries.push([name, grant.value])
}
}
const present =
entries.length > 0 ||
(fields.some((field) => field.present) &&
!(baseline !== null && fields.some((field) => !field.present)))
return { present, value: present ? Object.fromEntries(entries) : undefined }
}
export function reconcileSharedClaudeCredentialFields(
liveCredentials: string[],
lastWrittenCredentials: string | null
): string {
const sources = liveCredentials.map((credentials) => {
const parsed = parseCredentialObject(credentials)
if (!parsed) {
throw new Error('Cannot preserve malformed Claude runtime credentials')
}
return parsed
})
if (sources.length === 0) {
return '{}'
}
const baseline = parseCredentialObject(lastWrittenCredentials)
const entries: [string, unknown][] = []
for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) {
const field =
(key === 'mcpOAuth' || key === 'mcpOAuthClientConfig'
? resolveServerGrants(sources, baseline, key)
: null) ??
resolveCredentialField(
sources.map((source) => credentialField(source, key)),
baseline === null ? null : credentialField(baseline, key)
)
if (field.present) {
entries.push([key, field.value])
}
}
return JSON.stringify(Object.fromEntries(entries))
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields'
const original = { accessToken: 'access', refreshToken: 'refresh' }
const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' }
const baseline = JSON.stringify({
mcpOAuth: { figma: original },
pluginSecrets: { plugin: 'secret' }
})
describe('live Claude connector reconciliation', () => {
it('combines independent server grants on startup without importing account identity', () => {
const sources = [
JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }),
JSON.stringify({
mcpOAuth: { notion: rotated },
mcpOAuthClientConfig: { notion: { clientId: 'client' } }
}),
'{}'
]
const expected = {
mcpOAuth: { figma: original, notion: rotated },
mcpOAuthClientConfig: { notion: { clientId: 'client' } }
}
expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected)
expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual(
expected
)
})
it('combines independent rotations and additions against the previous write', () => {
const sources = [
JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }),
JSON.stringify({
mcpOAuth: { figma: original, notion: original },
pluginSecrets: { plugin: 'new-secret' }
}),
baseline
]
const expected = {
mcpOAuth: { figma: rotated, notion: original },
pluginSecrets: { plugin: 'new-secret' }
}
expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected)
expect(
JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline))
).toEqual(expected)
})
it('keeps a revocation while another store adds an unrelated server', () => {
const sources = [
JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }),
JSON.stringify({
mcpOAuth: { figma: original, notion: rotated },
pluginSecrets: { plugin: 'secret' }
}),
baseline
]
expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({
mcpOAuth: { notion: rotated },
pluginSecrets: { plugin: 'secret' }
})
})
it.each([null, baseline])(
'rejects conflicting server token pairs with baseline %s',
(previous) => {
const sources = [
JSON.stringify({ mcpOAuth: { figma: rotated } }),
JSON.stringify({
mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } }
})
]
expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow(
'live connector credentials conflict'
)
expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow(
'live connector credentials conflict'
)
}
)
it('does not recombine access and refresh tokens from different writes', () => {
const sources = [
JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }),
JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } })
]
expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow(
'live connector credentials conflict'
)
})
it('does not infer MCP freshness from Claude account-token expiry', () => {
const sources = [
JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }),
JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } })
]
expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow(
'live connector credentials conflict'
)
})
it('rejects conflicting non-server fields instead of combining their secrets', () => {
expect(() =>
reconcileSharedClaudeCredentialFields(
[
JSON.stringify({ pluginSecrets: { plugin: 'one' } }),
JSON.stringify({ pluginSecrets: { plugin: 'two' } })
],
null
)
).toThrow('live connector credentials conflict')
})
it('does not resurrect the last-written grants when every live store is missing', () => {
expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}')
})
it('treats an explicit null as an authoritative field removal after a known write', () => {
expect(
JSON.parse(
reconcileSharedClaudeCredentialFields(
[JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline],
baseline
)
)
).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } })
})
})
@@ -129,6 +129,7 @@ describe('preflight', () => {
})
afterEach(() => {
vi.restoreAllMocks()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
@@ -585,6 +586,7 @@ describe('preflight', () => {
})
it('uses the persisted Windows Path when probing host CLIs', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_000)
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'