Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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(
Expand All @@ -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
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
)
Comment on lines +74 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

set -eu
base=2dc98c93de675947958960061a335bcfbfb3f838
head=5cfcc85508ae37f9ee09a813a4219b42f53d9263
file=src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts
printf '%s\n' '--- diff ---'
git diff --no-ext-diff --unified=50 "$base" "$head" -- "$file"
printf '%s\n' '--- current file ---'
nl -ba "$file"
printf '%s\n' '--- relevant symbols and test references ---'
rg -n -F -e 'hasEmptyClaudeKeychainSnapshot' -e 'snapshotKeychainCredentials' -e 'partial' src

Repository: stablyai/orca

Length of output: 45130


🏁 Script executed:

set -eu
base=2dc98c93de675947958960061a335bcfbfb3f838
head=5cfcc85508ae37f9ee09a813a4219b42f53d9263
file=src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts
printf '%s\n' '--- diff ---'
git diff --no-ext-diff --unified=50 "$base" "$head" -- "$file"
printf '%s\n' '--- current file ---'
nl -ba "$file"
printf '%s\n' '--- relevant symbols and test references ---'
rg -n -F -e 'hasEmptyClaudeKeychainSnapshot' -e 'snapshotKeychainCredentials' -e 'partial' src || test "$?" -eq 1

Repository: stablyai/orca

Length of output: 45278


🏁 Script executed:

set -eu
dir=src/main/claude-accounts/runtime-auth
printf '%s\n' '--- runtime-auth files ---'
rg --files "$dir"
printf '%s\n' '--- exact symbol references ---'
rg -n -C 24 -F -e 'snapshotKeychainCredentials' -e 'hasEmptyClaudeKeychainSnapshot' -e 'scopedKeychainCredentialsEmpty' -e 'legacyKeychainCredentialsEmpty' "$dir" || test "$?" -eq 1
printf '%s\n' '--- related test declarations ---'
rg -n -C 8 'partial|rollback|managed.*selected|Keychain.*null|initial.*Keychain' "$dir" -g '*test*' || test "$?" -eq 1

Repository: stablyai/orca

Length of output: 36529


🏁 Script executed:

set -eu
dir=src/main/claude-accounts
printf '%s\n' '--- claude-account test files ---'
rg --files "$dir" | rg '(^|/)[^/]*(test|spec)\.'
printf '%s\n' '--- restore callers and rollback references ---'
rg -n -C 10 -F -e 'restoreSystemDefaultSnapshot' -e 'hasUnchangedActiveClaudeKeychainCredentials' -e 'writeActiveClaudeKeychainCredentials' -e 'removeActiveClaudeKeychainCredentials' "$dir" || test "$?" -eq 1
printf '%s\n' '--- complete restore implementation ---'
nl -ba "$dir/runtime-auth/runtime-auth-snapshot-restore.ts"
printf '%s\n' '--- keychain snapshot operations ---'
nl -ba "$dir/runtime-auth/runtime-auth-keychain-snapshots.ts" | sed -n '35,180p'

Repository: stablyai/orca

Length of output: 42616


🏁 Script executed:

set -eu
dir=src/main/claude-accounts
printf '%s\n' '--- sync write and rollback blocks ---'
rg -n -C 28 'writeActiveClaudeKeychainCredentials|restoreSystemDefaultSnapshot|catch \(error\)' "$dir/runtime-auth/runtime-auth-sync.ts" || test "$?" -eq 1
printf '%s\n' '--- ownership and file restoration helpers ---'
rg -n -C 20 'hasUnchangedRuntimeCredentials|hasUnchangedActiveClaudeKeychainCredentials|restoreRuntimeCredentials' "$dir/runtime-auth"
printf '%s\n' '--- keychain snapshot test cases ---'
rg -n -C 24 'partial|legacy|rollback|empty token|empty OAuth|Keychain' "$dir/runtime-auth-service-keychain-snapshots.test.ts" || test "$?" -eq 1

Repository: stablyai/orca

Length of output: 42647


🏁 Script executed:

set -eu
dir=src/main/claude-accounts
printf '%s\n' '--- Keychain read/write implementations ---'
rg -n -C 16 -F -e 'readActiveClaudeKeychainCredentialsStrict' -e 'writeActiveClaudeKeychainCredentialsForRuntime' "$dir/keychain.ts"
printf '%s\n' '--- ownership helpers ---'
rg -n -C 18 -F -e 'hasUnchangedRuntimeCredentials' -e 'hasUnchangedActiveClaudeKeychainCredentials' "$dir/runtime-auth"
printf '%s\n' '--- empty predicate call sites ---'
rg -n -F 'isEmptyClaudeOAuthCredentials(' "$dir"
printf '%s\n' '--- focused test setup and relevant test ---'
sed -n '1,115p' "$dir/runtime-auth-service-keychain-snapshots.test.ts"
sed -n '542,635p' "$dir/runtime-auth-service-keychain-snapshots.test.ts"

Repository: stablyai/orca

Length of output: 26799


🏁 Script executed:

set -eu
dir=src/main/claude-accounts
printf '%s\n' '--- shared credential merge definition ---'
rg -n -C 18 -F 'mergeSharedClaudeCredentialFields' "$dir"
printf '%s\n' '--- runtime credential restore definition ---'
rg -n -C 18 -F 'restoreRuntimeCredentials(' "$dir/runtime-auth"

Repository: stablyai/orca

Length of output: 41590


Treat null Keychain captures as empty rollback targets.

A successful Keychain read with no entry is recorded as captured with null, so the selected-account rollback guard is skipped. If the scoped write succeeds and the legacy write fails, rollback can restore the system credentials file and clear the managed OAuth credentials from the scoped Keychain while the account remains selected.

Classify null as empty. Also keep the pending rollback eligible for restoration if the account is deselected before a retry. Add a partial-write test with both initial Keychain entries set to null; check that managed auth survives the failure and that deselection restores the system file and clears the entries.

Suggested fix
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
@@
-    if (this.isEmptyClaudeOAuthCredentials(credentialsJson)) {
+    if (credentialsJson === null || this.isEmptyClaudeOAuthCredentials(credentialsJson)) {
       return { status: 'empty' }
     }

diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts
@@
-      if (this.lastSyncedAccountId !== null) {
+      if (this.lastSyncedAccountId !== null || this.lastWrittenCredentialsJson !== null) {
         await (previousAccount
           ? this.restoreSystemDefaultSnapshot(

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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()
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/claude-accounts/runtime-auth/runtime-auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type ClaudeSystemDefaultSnapshot = {
legacyKeychainCredentialsJson?: string | null
scopedKeychainCredentialsCaptured?: boolean
legacyKeychainCredentialsCaptured?: boolean
scopedKeychainCredentialsEmpty?: boolean
legacyKeychainCredentialsEmpty?: boolean
capturedAt: number
}

Expand All @@ -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 = {
Expand Down
Loading