From 987c74237dd34a08afa8c56df32f692e69edb06e Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 18:14:52 -0700 Subject: [PATCH] Isolate Claude auth ownership and migration --- .../claude-managed-auth-storage.ts | 10 +- .../legacy-shared-claude-auth-migration.ts | 117 +++++++++ src/main/claude-accounts/managed-auth-path.ts | 222 +++++++++++++----- .../claude-accounts/runtime-auth-service.ts | 25 ++ .../runtime-auth-managed-credentials.ts | 21 +- .../runtime-auth/runtime-auth-preparation.ts | 13 + .../runtime-auth/runtime-auth-readback.ts | 7 +- .../runtime-auth/runtime-auth-sync.ts | 17 +- 8 files changed, 345 insertions(+), 87 deletions(-) create mode 100644 src/main/claude-accounts/legacy-shared-claude-auth-migration.ts diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts index 970202c8210..97e4a71ae9a 100644 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -229,7 +229,13 @@ export class ClaudeManagedAuthStorage { if (process.platform !== 'win32') { if ( !existsSync(candidatePath) || - !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) || + lstatSync(candidatePath).isSymbolicLink() || + lstatSync(join(candidatePath, '.orca-managed-claude-auth')).isSymbolicLink() || + !lstatSync(join(candidatePath, '.orca-managed-claude-auth')).isFile() || + (expectedAccountId !== undefined && + readFileSync(join(candidatePath, '.orca-managed-claude-auth'), 'utf-8').trim() !== + expectedAccountId) ) { throw new Error('Managed Claude auth storage is not owned by Orca.') } @@ -249,6 +255,8 @@ export class ClaudeManagedAuthStorage { 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', 'candidate_real=$(readlink -f -- "$candidate")', 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test ! -L "$candidate"', + 'test ! -L "$candidate_real/.orca-managed-claude-auth"', 'test -f "$candidate_real/.orca-managed-claude-auth"', expected, 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' diff --git a/src/main/claude-accounts/legacy-shared-claude-auth-migration.ts b/src/main/claude-accounts/legacy-shared-claude-auth-migration.ts new file mode 100644 index 00000000000..2ec0da139ff --- /dev/null +++ b/src/main/claude-accounts/legacy-shared-claude-auth-migration.ts @@ -0,0 +1,117 @@ +import { mkdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { resolveOwnedClaudeManagedAuthPath } from './managed-auth-path' + +export const LEGACY_SHARED_CLAUDE_AUTH_MIGRATION_MARKER = + 'per-account-claude-auth-migration-v1.json' + +export type ClaudeAuthMigrationOutcome = + | 'migrated' + | 'already-present' + | 'no-shared-auth' + | 'ambiguous' + | 'unavailable' + +type MigrationOptions = { + accounts: ClaudeManagedAccount[] + sharedAuthPath: string + metadataDir: string + readLegacyKeychain?: () => Promise + readManagedCredentials: (account: ClaudeManagedAccount) => Promise + writeManagedCredentials: (account: ClaudeManagedAccount, contents: string) => Promise +} + +/** One-way migration for pre-isolation shared Claude credentials. */ +export async function migrateLegacySharedClaudeAuth( + options: MigrationOptions +): Promise { + const markerPath = join(options.metadataDir, LEGACY_SHARED_CLAUDE_AUTH_MIGRATION_MARKER) + try { + if (statSync(markerPath).isFile()) { + return 'already-present' + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + return 'unavailable' + } + } + + let shared: string | null = null + try { + shared = options.readLegacyKeychain + ? await options.readLegacyKeychain() + : readFileSync(options.sharedAuthPath, 'utf-8') + } catch (error) { + if (isDefinitiveAbsence(error)) { + return stamp(markerPath, 'no-shared-auth') + } + return 'unavailable' + } + if (!shared) { + return stamp(markerPath, 'no-shared-auth') + } + + const identity = parseIdentity(shared) + if (!identity) { + return 'unavailable' + } + const candidates = options.accounts.filter((account) => { + const emailMatch = identity.email && account.email.trim().toLowerCase() === identity.email + const orgMatch = identity.organizationUuid + ? account.organizationUuid === identity.organizationUuid + : true + return Boolean(emailMatch && orgMatch) + }) + if (candidates.length !== 1) { + return 'ambiguous' + } + const account = candidates[0] + if (!resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath)) { + return 'unavailable' + } + const existing = await options.readManagedCredentials(account) + if (!existing) { + try { + await options.writeManagedCredentials(account, shared) + } catch { + return 'unavailable' + } + } + return stamp(markerPath, existing ? 'already-present' : 'migrated', account.id) +} + +function parseIdentity( + contents: string +): { email: string | null; organizationUuid: string | null } | null { + try { + const root = JSON.parse(contents) as Record + const oauth = root.claudeAiOauth + if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) { + return null + } + const value = oauth as Record + const email = typeof value.email === 'string' ? value.email.trim().toLowerCase() : null + const organizationUuid = + typeof value.organizationUuid === 'string' ? value.organizationUuid.trim() : null + return email ? { email, organizationUuid } : null + } catch { + return null + } +} + +function stamp(path: string, outcome: string, accountId?: string): ClaudeAuthMigrationOutcome { + try { + mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 }) + writeFileAtomically( + path, + `${JSON.stringify({ version: 1, completedAt: Date.now(), outcome, accountId: accountId ?? null })}\n`, + { mode: 0o600 } + ) + return outcome as ClaudeAuthMigrationOutcome + } catch { + return 'unavailable' + } +} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts index 2914b72e033..de796773034 100644 --- a/src/main/claude-accounts/managed-auth-path.ts +++ b/src/main/claude-accounts/managed-auth-path.ts @@ -1,59 +1,150 @@ -import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' import { app } from 'electron' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { writeFileAtomically } from '../codex-accounts/fs-utils' const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' +export type ClaudeManagedAuthOwnershipVerdict = + | { kind: 'owned'; authPath: string } + | { kind: 'untrusted'; reason: string } + | { kind: 'indeterminate'; error: unknown } + +export class ClaudeManagedAuthTemporarilyUnavailableError extends Error { + constructor(options?: { cause?: unknown }) { + super('Claude managed auth storage is temporarily unavailable.', options) + } +} + export function getClaudeManagedAccountsRoot(): string { return join(app.getPath('userData'), 'claude-accounts') } +function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const value = relative(rootPath, candidatePath) + return value === '' || (!value.startsWith('..') && !value.includes(`..${sep}`)) +} + +function canonicalizeIfPresent(path: string): string { + try { + return realpathSync(path) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return resolve(path) + } + throw error + } +} + +/** Non-throwing ownership check; indeterminate must never be treated as absent. */ +export function resolveClaudeManagedAuthOwnership( + accountId: string, + candidatePath: string +): ClaudeManagedAuthOwnershipVerdict { + const resolvedCandidate = resolve(candidatePath) + const resolvedRoot = resolve(getClaudeManagedAccountsRoot()) + let canonicalCandidate: string + let canonicalRoot: string + let canonicalSystemHome: string + try { + if (lstatSync(resolvedRoot).isSymbolicLink()) { + return { kind: 'untrusted', reason: 'Claude managed accounts root is a symlink.' } + } + statSync(resolvedCandidate) + if (lstatSync(resolvedCandidate).isSymbolicLink()) { + return { kind: 'untrusted', reason: 'Claude managed auth path is a symlink.' } + } + canonicalRoot = realpathSync(resolvedRoot) + canonicalCandidate = realpathSync(resolvedCandidate) + canonicalSystemHome = canonicalizeIfPresent(join(homedir(), '.claude')) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: 'Managed Claude auth directory does not exist on disk.' } + } + return { kind: 'indeterminate', error } + } + + let canonicalExpected: string + try { + canonicalExpected = canonicalizeIfPresent(join(canonicalRoot, accountId, 'auth')) + } catch (error) { + return { kind: 'indeterminate', error } + } + if ( + !pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) || + canonicalCandidate === canonicalRoot || + canonicalCandidate !== canonicalExpected + ) { + return { kind: 'untrusted', reason: 'Managed Claude auth path is outside its account root.' } + } + if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) { + return { + kind: 'untrusted', + reason: 'Managed Claude auth resolves inside the system Claude home.' + } + } + + const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) + let markerContents: string + try { + const markerStat = lstatSync(markerPath) + if (!markerStat.isFile() || markerStat.isSymbolicLink()) { + return { kind: 'untrusted', reason: 'Managed Claude auth marker is not a regular file.' } + } + markerContents = readFileSync(markerPath, 'utf-8') + } catch (error) { + if (isDefinitiveAbsence(error)) { + return { kind: 'untrusted', reason: 'Managed Claude auth marker is missing.' } + } + return { kind: 'indeterminate', error } + } + if (markerContents.trim() !== accountId) { + return { + kind: 'untrusted', + reason: 'Managed Claude auth marker does not match its account ID.' + } + } + return { kind: 'owned', authPath: canonicalCandidate } +} + export function resolveOwnedClaudeManagedAuthPath( accountId: string, candidatePath: string, options: { adoptLegacyMarker?: boolean } = {} ): string | null { - const rootPath = getClaudeManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { - return null + let verdict = resolveClaudeManagedAuthOwnership(accountId, candidatePath) + if (verdict.kind === 'untrusted' && options.adoptLegacyMarker) { + const root = resolve(getClaudeManagedAccountsRoot()) + const expected = join(root, accountId, 'auth') + try { + if (resolve(candidatePath) === expected && statSync(expected).isDirectory()) { + writeFileSync(join(expected, MANAGED_AUTH_MARKER), `${accountId}\n`, { + encoding: 'utf-8', + mode: 0o600, + flag: 'wx' + }) + verdict = resolveClaudeManagedAuthOwnership(accountId, candidatePath) + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + return null + } + } } - try { - if (lstatSync(resolvedCandidate).isSymbolicLink()) { - return null - } - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(rootPath) - if ( - canonicalCandidate === canonicalRoot || - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - return null - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const relativeParts = relativePath.split(sep) - const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - if ( - escaped || - relativeParts.length !== 2 || - relativeParts[0] !== accountId || - relativeParts[1] !== 'auth' - ) { - return null - } - const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) - const markerValid = isManagedAuthMarkerValid(markerPath, accountId) - if (!markerValid && options.adoptLegacyMarker) { - writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) - } - if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { - return null - } - return canonicalCandidate - } catch { - return null + return verdict.kind === 'owned' ? verdict.authPath : null +} + +export function assertOwnedClaudeManagedAuthPath(accountId: string, candidatePath: string): string { + const verdict = resolveClaudeManagedAuthOwnership(accountId, candidatePath) + if (verdict.kind === 'owned') { + return verdict.authPath } + if (verdict.kind === 'indeterminate') { + throw new ClaudeManagedAuthTemporarilyUnavailableError({ cause: verdict.error }) + } + throw new Error(verdict.reason) } export function readClaudeManagedAuthFile( @@ -66,8 +157,11 @@ export function readClaudeManagedAuthFile( return null } return readFileSync(filePath, 'utf-8') - } catch { - return null + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw error } } @@ -77,36 +171,36 @@ export function writeClaudeManagedAuthFile( contents: string ): void { const filePath = resolve(managedAuthPath, filename) - if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { + if (!isOwnedChildFile(managedAuthPath, filePath, true)) { throw new Error('Managed Claude auth child file is not owned by Orca.') } writeFileAtomically(filePath, contents, { mode: 0o600 }) } -function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { +function isOwnedChildFile( + managedAuthPath: string, + filePath: string, + allowMissing = false +): boolean { try { - if ( - !existsSync(markerPath) || - lstatSync(markerPath).isSymbolicLink() || - !lstatSync(markerPath).isFile() - ) { + const authStat = lstatSync(managedAuthPath) + if (!authStat.isDirectory() || authStat.isSymbolicLink()) { return false } - return readFileSync(markerPath, 'utf-8').trim() === accountId - } catch { - return false + const fileStat = lstatSync(filePath) + if (fileStat.isSymbolicLink() || (!fileStat.isFile() && !allowMissing)) { + return false + } + const canonicalAuthPath = realpathSync(managedAuthPath) + const canonicalFilePath = allowMissing ? resolve(filePath) : realpathSync(filePath) + return ( + pathIsInsideOrEqual(canonicalAuthPath, canonicalFilePath) && + canonicalFilePath !== canonicalAuthPath + ) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return allowMissing + } + throw error } } - -function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { - if ( - !existsSync(filePath) || - lstatSync(filePath).isSymbolicLink() || - !lstatSync(filePath).isFile() - ) { - return false - } - const canonicalAuthPath = realpathSync(managedAuthPath) - const canonicalFilePath = realpathSync(filePath) - return canonicalFilePath.startsWith(canonicalAuthPath + sep) -} diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 1866cb7e259..07d30c8f01b 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -5,6 +5,8 @@ import { } from './runtime-selection' import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { migrateLegacySharedClaudeAuth } from './legacy-shared-claude-auth-migration' +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -13,6 +15,7 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { super(store) this.initializeLastSyncedState() void this.safeSyncForCurrentSelection() + void this.migrateLegacySharedAuth() } async prepareForClaudeLaunch( @@ -81,6 +84,28 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { } } + private async migrateLegacySharedAuth(): Promise { + const settings = this.store.getSettings() + const paths = this.pathResolver.getRuntimePaths() + const metadataDir = this.getRuntimeMetadataDir() + try { + await migrateLegacySharedClaudeAuth({ + accounts: settings.claudeManagedAccounts, + sharedAuthPath: paths.credentialsPath, + metadataDir, + readLegacyKeychain: + process.platform === 'darwin' + ? () => readActiveClaudeKeychainCredentialsStrict() + : undefined, + readManagedCredentials: (account) => this.readManagedCredentials(account), + writeManagedCredentials: (account, contents) => + this.writeManagedCredentials(account, contents) + }) + } catch (error) { + console.warn('[claude-runtime-auth] Legacy auth migration deferred:', error) + } + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) 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..dbb32c6d6a2 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 @@ -4,7 +4,9 @@ import { parseWslUncPath } from '../../../shared/wsl-paths' import { toWindowsWslPath } from '../../wsl' import { runWslProcess } from '../../wsl/wsl-runner' import { + assertOwnedClaudeManagedAuthPath, readClaudeManagedAuthFile, + resolveClaudeManagedAuthOwnership, resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from '../managed-auth-path' @@ -137,8 +139,21 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden } return existsSync(account.managedAuthPath) ? account.managedAuthPath : null } - return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { - adoptLegacyMarker: true - }) + try { + const verdict = resolveClaudeManagedAuthOwnership(account.id, account.managedAuthPath) + if (verdict.kind === 'indeterminate') { + return assertOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath) + } + return verdict.kind === 'owned' + ? verdict.authPath + : resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { + adoptLegacyMarker: true + }) + } catch (error) { + if (error instanceof Error && error.message.includes('temporarily unavailable')) { + throw error + } + return null + } } } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts index 2ad0ee9999a..2b80c4a8126 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -21,6 +21,19 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh ) const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) + // An explicit user config root is authoritative; account selection must not + // silently redirect a pane away from it. + if (process.env.CLAUDE_CONFIG_DIR?.trim()) { + return { + configDir: paths.configDir, + runtime: normalizedTarget.runtime, + wslDistro: normalizedTarget.wslDistro, + wslLinuxConfigDir: null, + envPatch: paths.envPatch, + stripAuthEnv: false, + provenance: 'system:explicit-config-dir' + } + } if ( normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' && activeAccount?.managedAuthRuntime === 'wsl' && 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 340b6280e59..bdbb9035e2a 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from 'node:fs' -import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' import { startSpan } from '../../observability/tracer' import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' import type { @@ -114,10 +113,8 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi if (options.updateLastWrittenCredentialsJson) { this.writeRuntimeCredentials(runtimeContents) this.lastWrittenCredentialsJson = runtimeContents - if (process.platform === 'darwin') { - const paths = this.pathResolver.getRuntimePaths() - await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir) - } + // The managed account remains the source of truth; do not write the + // shared active Keychain service for an isolated account. } decisionSpan.end() return { status: 'persisted' } 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 0f2c7f5ac02..e987cfe9a84 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -8,7 +8,6 @@ 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 { @@ -263,20 +262,10 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { } } - const paths = this.pathResolver.getRuntimePaths() this.writeRuntimeCredentials(credentialsJson) - 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) - } catch (error) { - await this.restoreSystemDefaultSnapshot( - credentialsJson, - await this.readManagedOauthAccount(activeAccount) - ) - throw error - } - } + // 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). const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) if (this.writeRuntimeOauthAccount(managedOauthAccount)) { this.lastWrittenOauthAccount = managedOauthAccount