mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
co-authored by
tomarai85
Neil
parent
d73efccc7d
commit
1b52be6255
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user