Isolate Claude auth ownership and migration

This commit is contained in:
Merge Sim
2026-08-31 19:44:36 -07:00
parent 93e58124d7
commit 987c74237d
8 changed files with 345 additions and 87 deletions
@@ -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'
@@ -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<string | null>
readManagedCredentials: (account: ClaudeManagedAccount) => Promise<string | null>
writeManagedCredentials: (account: ClaudeManagedAccount, contents: string) => Promise<void>
}
/** One-way migration for pre-isolation shared Claude credentials. */
export async function migrateLegacySharedClaudeAuth(
options: MigrationOptions
): Promise<ClaudeAuthMigrationOutcome> {
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<string, unknown>
const oauth = root.claudeAiOauth
if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) {
return null
}
const value = oauth as Record<string, unknown>
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'
}
}
+158 -64
View File
@@ -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)
}
@@ -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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
@@ -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
}
}
}
@@ -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' &&
@@ -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' }
@@ -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