diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts index 970202c8210..b430979655c 100644 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -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 { const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (!parseWslUncPath(trustedPath)) { + credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) + } if (process.platform === 'darwin') { await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) } else { diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts index 177d8f9c013..249e403b075 100644 --- a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts @@ -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() }) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts index 2b5c96cf136..94bdab9b4d8 100644 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -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 () => { diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts new file mode 100644 index 00000000000..4066063c562 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts @@ -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('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) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts new file mode 100644 index 00000000000..f3bb1f00137 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts @@ -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('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) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts new file mode 100644 index 00000000000..8cb00420dc6 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts @@ -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 = 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 } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts index e94801a8eec..d029ae0d699 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts @@ -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 try { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts index 25afacc9942..ef4c54acab8 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts @@ -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 diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts index ad68d59eadb..3a93f63d4dd 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -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 diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts index 77dfea27184..848ee093dfb 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -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) diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts index d99af570557..40d4d45395a 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts @@ -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 { + 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 { - 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)) } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts index eb1aea611e1..e184ec4e751 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts @@ -12,7 +12,7 @@ export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback ): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (runtimeCredentialsJson !== managedCredentialsJson) { + if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { await this.captureSystemDefaultSnapshot({ force: true, previousSnapshot: existingSnapshot, diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts index 76ccd342d84..bb2158d9464 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts @@ -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() } } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts index d25ee84b44a..6d6e5e47a48 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts @@ -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 diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts index f191689a446..23e9112d7ed 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -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 diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts new file mode 100644 index 00000000000..097e1725d12 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.test.ts @@ -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 = { 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') + }) +}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts new file mode 100644 index 00000000000..e0014f25c4b --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.ts @@ -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 | 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 { + 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 = { ...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, 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[], + baseline: Record | 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)) +} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts new file mode 100644 index 00000000000..f772b5634c9 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-reconciliation.test.ts @@ -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' } }) + }) +}) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index b68c3e8afb5..7f6448a9bc3 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -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'