diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts index 7a80c5be002a..dcd3e3a6d044 100644 --- a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts @@ -575,6 +575,64 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.legacyKeychainCredentials).toBe(systemCredentials) }) + it('keeps the selected managed auth when an empty Keychain snapshot follows a partial write failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const emptyCredentials = JSON.stringify({ + claudeAiOauth: { accessToken: '', refreshToken: '', expiresAt: 0 } + }) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = emptyCredentials + testState.legacyKeychainCredentials = emptyCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(emptyCredentials) + const snapshotJson = readFileSync(snapshotPath, 'utf-8') + expect(snapshotJson).not.toContain(emptyCredentials) + expect(JSON.parse(snapshotJson)).toMatchObject({ + scopedKeychainCredentialsCaptured: false, + legacyKeychainCredentialsCaptured: false, + scopedKeychainCredentialsEmpty: true, + legacyKeychainCredentialsEmpty: true + }) + + testState.throwLegacyRuntimeKeychainWrite = false + await service.syncForCurrentSelection() + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(managedCredentials) + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + }) + it('restores scoped keychain after legacy runtime keychain write fails', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') 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 ef4c54acab83..ae41d4b08318 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 @@ -21,6 +21,8 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && + this.isOptionalBoolean(snapshot.scopedKeychainCredentialsEmpty) && + this.isOptionalBoolean(snapshot.legacyKeychainCredentialsEmpty) && this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') @@ -40,18 +42,19 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, service: 'scoped' | 'legacy', managedCredentialsJson: string | undefined - ): string | null { + ): ClaudeKeychainSnapshotValue { + if (this.isEmptyClaudeOAuthCredentials(credentialsJson)) { + return { status: 'empty' } + } if ( managedCredentialsJson && this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && previousSnapshot ) { const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) - if (previousValue.status === 'captured') { - return previousValue.credentialsJson - } + return previousValue } - return credentialsJson + return { status: 'captured', credentialsJson } } protected hasValidKeychainSnapshotValue( @@ -62,6 +65,11 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged service === 'scoped' ? 'scopedKeychainCredentialsCaptured' : 'legacyKeychainCredentialsCaptured' + const emptyKey = + service === 'scoped' ? 'scopedKeychainCredentialsEmpty' : 'legacyKeychainCredentialsEmpty' + if (snapshot[emptyKey] === true) { + return snapshot[capturedKey] !== true + } if (snapshot[capturedKey] === false) { return true } @@ -83,18 +91,53 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged service === 'scoped' ? 'scopedKeychainCredentialsCaptured' : 'legacyKeychainCredentialsCaptured' + const emptyKey = + service === 'scoped' ? 'scopedKeychainCredentialsEmpty' : 'legacyKeychainCredentialsEmpty' + if (snapshot[emptyKey] === true) { + return { status: 'empty' } + } if (snapshot[capturedKey] === false) { return { status: 'unknown' } } const credentialsKey = service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' + const credentialsJson = Object.hasOwn(snapshot, credentialsKey) + ? (snapshot[credentialsKey] ?? null) + : snapshot.keychainCredentialsJson + if (this.isEmptyClaudeOAuthCredentials(credentialsJson)) { + return { status: 'empty' } + } if (Object.hasOwn(snapshot, credentialsKey)) { return { status: 'captured', - credentialsJson: snapshot[credentialsKey] ?? null + credentialsJson: credentialsJson ?? null } } - return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } + return { status: 'captured', credentialsJson: credentialsJson ?? null } + } + + protected hasEmptyClaudeKeychainSnapshot(snapshot: ClaudeSystemDefaultSnapshot | null): boolean { + return ( + this.readKeychainSnapshotValue(snapshot, 'scoped').status === 'empty' || + this.readKeychainSnapshotValue(snapshot, 'legacy').status === 'empty' + ) + } + + protected isEmptyClaudeOAuthCredentials(credentialsJson: string | null | undefined): boolean { + if (typeof credentialsJson !== 'string') { + return false + } + try { + const credentials = this.asRecord(JSON.parse(credentialsJson)) + const oauth = this.asRecord(credentials?.claudeAiOauth) + return ( + oauth !== null && + this.normalizeField(this.readString(oauth, 'accessToken')) === null && + this.normalizeField(this.readString(oauth, 'refreshToken')) === null + ) + } catch { + return false + } } protected async readAggregateClaudeKeychainCredentialsBestEffort( 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 e184ec4e7517..29f9a16c863b 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 @@ -50,9 +50,13 @@ export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback : existsSync(paths.credentialsPath) ? readFileSync(paths.credentialsPath, 'utf-8') : null - const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( - paths.configDir + const aggregateKeychainCredentialsJson = + await this.readAggregateClaudeKeychainCredentialsBestEffort(paths.configDir) + const keychainCredentialsJson = this.isEmptyClaudeOAuthCredentials( + aggregateKeychainCredentialsJson ) + ? null + : aggregateKeychainCredentialsJson const scopedKeychainCredentials = process.platform === 'darwin' ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) @@ -67,34 +71,34 @@ export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback ) { throw new Error('Cannot capture current Claude Keychain credentials') } - const scopedKeychainCredentialsJson = - scopedKeychainCredentials.status === 'captured' - ? this.snapshotKeychainCredentials( - scopedKeychainCredentials.credentialsJson, - options.previousSnapshot, - 'scoped', - options.managedCredentialsJson - ) - : undefined - const legacyKeychainSnapshotJson = - legacyKeychainCredentialsJson.status === 'captured' - ? this.snapshotKeychainCredentials( - legacyKeychainCredentialsJson.credentialsJson, - options.previousSnapshot, - 'legacy', - options.managedCredentialsJson - ) - : undefined + const scopedKeychainSnapshot = this.snapshotKeychainCredentials( + scopedKeychainCredentials.credentialsJson, + options.previousSnapshot, + 'scoped', + options.managedCredentialsJson + ) + const legacyKeychainSnapshot = this.snapshotKeychainCredentials( + legacyKeychainCredentialsJson.credentialsJson, + options.previousSnapshot, + 'legacy', + options.managedCredentialsJson + ) const configOauthAccount = this.readRuntimeOauthAccount() const snapshot: ClaudeSystemDefaultSnapshot = { credentialsJson, configOauthAccount: configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, keychainCredentialsJson, - scopedKeychainCredentialsJson, - legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, - scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', - legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', + ...(scopedKeychainSnapshot.status === 'captured' + ? { scopedKeychainCredentialsJson: scopedKeychainSnapshot.credentialsJson } + : {}), + ...(legacyKeychainSnapshot.status === 'captured' + ? { legacyKeychainCredentialsJson: legacyKeychainSnapshot.credentialsJson } + : {}), + scopedKeychainCredentialsCaptured: scopedKeychainSnapshot.status === 'captured', + legacyKeychainCredentialsCaptured: legacyKeychainSnapshot.status === 'captured', + scopedKeychainCredentialsEmpty: scopedKeychainSnapshot.status === 'empty', + legacyKeychainCredentialsEmpty: legacyKeychainSnapshot.status === 'empty', capturedAt: Date.now() } this.writeJson(snapshotPath, snapshot) 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 bb2158d9464b..f151471a87b1 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 @@ -19,6 +19,13 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC let hasCredentialSurfaceOwnership = fileCredentialsOwned // Why: prove ownership before mutating anything, and restore OAuth first so a failure leaves the credential proof intact for retry. this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson + if ( + this.store.getSettings().activeClaudeManagedAccountId && + this.hasEmptyClaudeKeychainSnapshot(snapshot) + ) { + // Why: an empty default token is not a safe rollback target while its managed account remains selected; the next sync can retry materialization. + return + } let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null let legacySnapshot: ClaudeKeychainSnapshotValue | null = null let scopedKeychainOwned = false @@ -50,16 +57,16 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) } if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + if (scopedSnapshot && scopedSnapshot.status !== 'unknown' && scopedKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, + scopedSnapshot.status === 'empty' ? null : scopedSnapshot.credentialsJson, paths.configDir, sharedCredentialsJson ) } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + if (legacySnapshot && legacySnapshot.status !== 'unknown' && legacyKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, + legacySnapshot.status === 'empty' ? null : legacySnapshot.credentialsJson, undefined, sharedCredentialsJson ) @@ -68,8 +75,8 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC this.recordRestoredSharedCredentials( sharedCredentialsJson, fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned + scopedSnapshot !== null && scopedSnapshot.status !== 'unknown' && scopedKeychainOwned, + legacySnapshot !== null && legacySnapshot.status !== 'unknown' && legacyKeychainOwned ) this.lastWrittenCredentialsJson = null this.lastWrittenOauthAccount = null @@ -213,16 +220,16 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) } if (process.platform === 'darwin') { - if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + if (scopedSnapshot && scopedSnapshot.status !== 'unknown' && scopedKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( - scopedSnapshot.credentialsJson, + scopedSnapshot.status === 'empty' ? null : scopedSnapshot.credentialsJson, paths.configDir, sharedCredentialsJson ) } - if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + if (legacySnapshot && legacySnapshot.status !== 'unknown' && legacyKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( - legacySnapshot.credentialsJson, + legacySnapshot.status === 'empty' ? null : legacySnapshot.credentialsJson, undefined, sharedCredentialsJson ) @@ -231,8 +238,8 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC this.recordRestoredSharedCredentials( sharedCredentialsJson, fileCredentialsOwned, - scopedSnapshot?.status === 'captured' && scopedKeychainOwned, - legacySnapshot?.status === 'captured' && legacyKeychainOwned + scopedSnapshot !== null && scopedSnapshot.status !== 'unknown' && scopedKeychainOwned, + legacySnapshot !== null && legacySnapshot.status !== 'unknown' && legacyKeychainOwned ) this.clearLastWrittenRuntimeState() } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..13ce82a56b1d 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -20,6 +20,8 @@ export type ClaudeSystemDefaultSnapshot = { legacyKeychainCredentialsJson?: string | null scopedKeychainCredentialsCaptured?: boolean legacyKeychainCredentialsCaptured?: boolean + scopedKeychainCredentialsEmpty?: boolean + legacyKeychainCredentialsEmpty?: boolean capturedAt: number } @@ -45,6 +47,7 @@ export type ClaudeKeychainReadResult = | { status: 'failed' } export type ClaudeKeychainSnapshotValue = | { status: 'captured'; credentialsJson: string | null } + | { status: 'empty' } | { status: 'unknown' } export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' export type ClaudeRuntimeCredentialCandidate = {