fix(claude-auth): preserve legacy shared runtime compatibility

This commit is contained in:
Merge Sim
2026-08-31 19:44:37 -07:00
parent aefe7e67fa
commit 49b484a8f0
4 changed files with 74 additions and 14 deletions
@@ -14,8 +14,9 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync {
constructor(store: Store) {
super(store)
this.initializeLastSyncedState()
void this.safeSyncForCurrentSelection()
void this.migrateLegacySharedAuth()
// Sync the selected runtime first; migration must not race a cleanup and
// repopulate a managed account from a stale shared Keychain entry.
void this.safeSyncForCurrentSelection().finally(() => this.migrateLegacySharedAuth())
}
async prepareForClaudeLaunch(
@@ -28,7 +29,32 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync {
? settings.claudeManagedAccounts.find((account) => account.id === selectedId)
: null
// Isolated accounts are already Claude's runtime store; never copy them into ~/.claude.
if (!selected || selected.managedAuthRuntime === 'wsl') {
// Legacy accounts with valid credentials still use the shared runtime and
// must be synchronized before launch; missing legacy credentials are left
// for the background cleanup path to handle without a second restore.
const legacyCredentials =
selected && selected.managedAuthRuntime === undefined
? await this.readManagedCredentials(selected)
: null
let cleanupMissingLegacy = false
if (selected && selected.managedAuthRuntime === undefined && legacyCredentials === null) {
const runtimeCredentials = this.readRuntimeCredentialsFile()
const managedOauth = await this.readManagedOauthAccount(selected)
const runtimeMatches = this.runtimeCredentialsBelongToAccount(
runtimeCredentials,
selected,
managedOauth
)
cleanupMissingLegacy = runtimeMatches
}
if (
!selected ||
selected.managedAuthRuntime === 'wsl' ||
(selected.managedAuthRuntime === undefined &&
legacyCredentials !== null &&
this.isValidCredentialsJsonObject(legacyCredentials)) ||
cleanupMissingLegacy
) {
await this.syncForCurrentSelection(effectiveTarget)
}
return this.getPreparation(effectiveTarget)
@@ -77,7 +77,7 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh
provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system`
}
}
if (activeAccount?.managedAuthRuntime !== 'wsl' && activeAccount) {
if (activeAccount?.managedAuthRuntime === 'host') {
const managedPath = resolveOwnedClaudeManagedAuthPath(
activeAccount.id,
activeAccount.managedAuthPath,
@@ -101,14 +101,14 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh
wslDistro: null,
wslLinuxConfigDir: null,
envPatch: paths.envPatch,
stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'),
stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime === 'host'),
managedRefreshDeferredByLivePty: Boolean(
activeAccountId &&
activeAccount?.managedAuthRuntime !== 'wsl' &&
activeAccount?.managedAuthRuntime === 'host' &&
this.managedRefreshDeferredByLivePtyAccountId === activeAccountId
),
provenance:
activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'
activeAccountId && activeAccount?.managedAuthRuntime === 'host'
? `managed:${activeAccountId}`
: 'system'
}
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs'
import { startSpan } from '../../observability/tracer'
import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain'
import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching'
import type {
ClaudeReadBackMatch,
@@ -113,8 +114,10 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi
if (options.updateLastWrittenCredentialsJson) {
this.writeRuntimeCredentials(runtimeContents)
this.lastWrittenCredentialsJson = runtimeContents
// The managed account remains the source of truth; do not write the
// shared active Keychain service for an isolated account.
if (process.platform === 'darwin' && match.account.managedAuthRuntime === undefined) {
const paths = this.pathResolver.getRuntimePaths()
await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir)
}
}
decisionSpan.end()
return { status: 'persisted' }
@@ -8,6 +8,7 @@ import {
} from '../runtime-selection'
import { hasLiveClaudePtys } from '../live-pty-gate'
import { isOauthTokenExpiring } from '../oauth-refresh'
import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain'
import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation'
export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
@@ -142,7 +143,17 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
console.warn(
'[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default'
)
if (this.lastSyncedAccountId !== null) {
if (activeAccount.managedAuthRuntime === undefined) {
// Legacy accounts cannot safely restore a missing managed credential;
// retain the shared runtime while ensuring a valid snapshot marker.
if (!this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())) {
await this.captureSystemDefaultSnapshot({ force: false })
}
}
if (
this.lastSyncedAccountId !== null &&
(activeAccount.managedAuthRuntime !== undefined || previousAccount?.id !== activeAccount.id)
) {
if (
previousAccount &&
(previousAccount.id !== activeAccount.id ||
@@ -167,7 +178,15 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
console.warn(
'[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default'
)
if (this.lastSyncedAccountId !== null) {
if (activeAccount.managedAuthRuntime === undefined) {
if (!this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())) {
await this.captureSystemDefaultSnapshot({ force: false })
}
}
if (
this.lastSyncedAccountId !== null &&
(activeAccount.managedAuthRuntime !== undefined || previousAccount?.id !== activeAccount.id)
) {
if (
previousAccount &&
(previousAccount.id !== activeAccount.id ||
@@ -263,9 +282,21 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService {
}
this.writeRuntimeCredentials(credentialsJson)
// Isolated accounts are self-contained config roots. Never mirror their
// credentials into either active Keychain service (the legacy service is
// shared by all accounts and creates stale siblings).
// Legacy accounts predate per-account runtime isolation and still need both
// Keychain services for old and new Claude Code builds. Isolated accounts
// must never touch the shared active services.
if (process.platform === 'darwin' && activeAccount.managedAuthRuntime === undefined) {
const paths = this.pathResolver.getRuntimePaths()
try {
await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir)
} catch (error) {
await this.restoreSystemDefaultSnapshot(
credentialsJson,
await this.readManagedOauthAccount(activeAccount)
)
throw error
}
}
const managedOauthAccount = await this.readManagedOauthAccount(activeAccount)
if (this.writeRuntimeOauthAccount(managedOauthAccount)) {
this.lastWrittenOauthAccount = managedOauthAccount