mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Split Claude runtime auth responsibilities (#17268)
* Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Split Linear item drawer responsibilities * Split telemetry event schema responsibilities * Split resource usage status responsibilities * Split remote terminal multiplexer responsibilities * Split Git worktree responsibilities * Split Codex hook service responsibilities * Keep mirrored hook trust type private * Split web runtime session responsibilities * Split GitHub project view read path * Split Claude runtime auth responsibilities * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161
This commit is contained in:
@@ -6,7 +6,6 @@ inline src/main/agent-hooks/server.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/index.ts
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage'
|
||||
import type {
|
||||
ClaudeAuthIdentity,
|
||||
ClaudeReadBackMatch,
|
||||
ClaudeRefreshTokenComparison
|
||||
} from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage {
|
||||
protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const oauth = this.asRecord(parsed.claudeAiOauth)
|
||||
return {
|
||||
accountUuid: this.normalizeField(
|
||||
this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId')
|
||||
),
|
||||
email: this.normalizeField(this.readString(oauth, 'email')),
|
||||
organizationUuid: this.normalizeField(
|
||||
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected isValidCredentialsJsonObject(credentialsJson: string): boolean {
|
||||
try {
|
||||
const parsed = this.asRecord(JSON.parse(credentialsJson))
|
||||
const oauth = this.asRecord(parsed?.claudeAiOauth)
|
||||
return this.normalizeField(this.readString(oauth, 'accessToken')) !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
protected runtimeCredentialsAreFresher(
|
||||
runtimeCredentialsJson: string,
|
||||
managedCredentialsJson: string
|
||||
): boolean {
|
||||
const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson)
|
||||
const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson)
|
||||
return (
|
||||
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness
|
||||
)
|
||||
}
|
||||
|
||||
protected runtimeCredentialsAreOlder(
|
||||
runtimeCredentialsJson: string,
|
||||
managedCredentialsJson: string
|
||||
): boolean {
|
||||
const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson)
|
||||
const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson)
|
||||
return (
|
||||
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness
|
||||
)
|
||||
}
|
||||
|
||||
protected chooseFreshestReadBackCandidate(
|
||||
candidates: {
|
||||
credentialsJson: string
|
||||
match: Extract<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
}[]
|
||||
): {
|
||||
credentialsJson: string
|
||||
match: Extract<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
} {
|
||||
return candidates.reduce((freshest, candidate) => {
|
||||
const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson)
|
||||
const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson)
|
||||
if (
|
||||
candidateFreshness !== null &&
|
||||
(freshestFreshness === null || candidateFreshness > freshestFreshness)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
return freshest
|
||||
})
|
||||
}
|
||||
|
||||
protected readFreshnessFromCredentials(credentialsJson: string): number | null {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const oauth = this.asRecord(parsed.claudeAiOauth)
|
||||
return (
|
||||
this.readNumber(oauth, 'expiresAt') ??
|
||||
this.readNumber(oauth, 'expires_at') ??
|
||||
this.readNumber(oauth, 'expiry') ??
|
||||
this.readNumber(oauth, 'expires')
|
||||
)
|
||||
}
|
||||
|
||||
protected compareRefreshTokens(
|
||||
runtimeCredentialsJson: string,
|
||||
managedCredentialsJson: string
|
||||
): ClaudeRefreshTokenComparison {
|
||||
const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson)
|
||||
const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson)
|
||||
if (!runtimeRefreshToken || !managedRefreshToken) {
|
||||
return 'missing'
|
||||
}
|
||||
return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different'
|
||||
}
|
||||
|
||||
protected readRefreshTokenFromCredentials(credentialsJson: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
const oauth = this.asRecord(parsed.claudeAiOauth)
|
||||
return this.normalizeField(this.readString(oauth, 'refreshToken'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity {
|
||||
const oauth = this.asRecord(oauthAccount)
|
||||
return {
|
||||
accountUuid: this.normalizeField(
|
||||
this.readString(oauth, 'accountUuid') ?? this.readString(oauth, 'accountId')
|
||||
),
|
||||
email: this.normalizeField(
|
||||
this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email')
|
||||
),
|
||||
organizationUuid: this.normalizeField(
|
||||
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
protected readString(value: Record<string, unknown> | null, key: string): string | null {
|
||||
const candidate = value?.[key]
|
||||
return typeof candidate === 'string' ? candidate : null
|
||||
}
|
||||
|
||||
protected readNumber(value: Record<string, unknown> | null, key: string): number | null {
|
||||
const candidate = value?.[key]
|
||||
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
if (typeof candidate === 'string') {
|
||||
const parsed = Number(candidate)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected normalizeField(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
protected jsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return (
|
||||
JSON.stringify(this.sortJsonValue(left ?? null)) ===
|
||||
JSON.stringify(this.sortJsonValue(right ?? null))
|
||||
)
|
||||
}
|
||||
|
||||
protected sortJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.sortJsonValue(item))
|
||||
}
|
||||
const record = this.asRecord(value)
|
||||
if (!record) {
|
||||
return value
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(record)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state'
|
||||
import type { ClaudeReadBackMatch } from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState {
|
||||
protected async findManagedAccountForRuntimeCredentials(
|
||||
runtimeCredentialsJson: string,
|
||||
runtimeOauthAccount: unknown
|
||||
): Promise<ClaudeReadBackMatch> {
|
||||
const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = []
|
||||
let unverifiableCount = 0
|
||||
for (const account of this.store.getSettings().claudeManagedAccounts) {
|
||||
const managedCredentialsJson = await this.readManagedCredentials(account)
|
||||
if (!managedCredentialsJson) {
|
||||
continue
|
||||
}
|
||||
const match = this.runtimeCredentialsMatchAccount(
|
||||
runtimeCredentialsJson,
|
||||
runtimeOauthAccount,
|
||||
account,
|
||||
managedCredentialsJson,
|
||||
await this.readManagedOauthAccount(account)
|
||||
)
|
||||
if (match === 'match') {
|
||||
matches.push({ account, managedCredentialsJson })
|
||||
} else if (match === 'unverifiable') {
|
||||
unverifiableCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1 && unverifiableCount === 0) {
|
||||
return { kind: 'matched', ...matches[0] }
|
||||
}
|
||||
return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' }
|
||||
}
|
||||
|
||||
protected runtimeCredentialsMatchAccount(
|
||||
runtimeCredentialsJson: string,
|
||||
runtimeOauthAccount: unknown,
|
||||
account: ClaudeManagedAccount,
|
||||
managedCredentialsJson: string,
|
||||
managedOauthAccount: unknown
|
||||
): 'match' | 'mismatch' | 'unverifiable' {
|
||||
const identity = this.readIdentityFromCredentials(runtimeCredentialsJson)
|
||||
if (!identity) {
|
||||
return 'mismatch'
|
||||
}
|
||||
const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson)
|
||||
const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
|
||||
const runtimeOauthIdentity = this.readIdentityFromOauthAccount(runtimeOauthAccount)
|
||||
const credentialOauthConflict =
|
||||
(identity.accountUuid &&
|
||||
runtimeOauthIdentity.accountUuid &&
|
||||
identity.accountUuid !== runtimeOauthIdentity.accountUuid) ||
|
||||
(identity.email &&
|
||||
runtimeOauthIdentity.email &&
|
||||
identity.email !== runtimeOauthIdentity.email) ||
|
||||
(identity.organizationUuid &&
|
||||
runtimeOauthIdentity.organizationUuid &&
|
||||
identity.organizationUuid !== runtimeOauthIdentity.organizationUuid)
|
||||
if (credentialOauthConflict) {
|
||||
return 'mismatch'
|
||||
}
|
||||
|
||||
// Why: mirrors the Codex runtime-home guard; don't persist shared runtime creds into the managed account if another login rewrote them.
|
||||
const selectedOrganizationUuid = this.normalizeField(
|
||||
account.organizationUuid ??
|
||||
managedIdentity?.organizationUuid ??
|
||||
managedOauthIdentity.organizationUuid
|
||||
)
|
||||
const oauthAccountMatches =
|
||||
Boolean(managedOauthIdentity.accountUuid) &&
|
||||
managedOauthIdentity.accountUuid === runtimeOauthIdentity.accountUuid &&
|
||||
Boolean(runtimeOauthIdentity.email || runtimeOauthIdentity.organizationUuid)
|
||||
const runtimeEmail = identity.email ?? runtimeOauthIdentity.email
|
||||
const runtimeOrganizationUuid =
|
||||
identity.organizationUuid ?? runtimeOauthIdentity.organizationUuid
|
||||
const refreshTokenComparison = this.compareRefreshTokens(
|
||||
runtimeCredentialsJson,
|
||||
managedCredentialsJson
|
||||
)
|
||||
if (!runtimeEmail) {
|
||||
if (refreshTokenComparison === 'same') {
|
||||
return 'match'
|
||||
}
|
||||
if (identity.organizationUuid) {
|
||||
if (selectedOrganizationUuid && selectedOrganizationUuid !== identity.organizationUuid) {
|
||||
return 'mismatch'
|
||||
}
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (oauthAccountMatches) {
|
||||
return 'match'
|
||||
}
|
||||
if (!runtimeOrganizationUuid && refreshTokenComparison === 'different') {
|
||||
return 'mismatch'
|
||||
}
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (account.email && this.normalizeField(account.email) !== runtimeEmail) {
|
||||
return 'mismatch'
|
||||
}
|
||||
if (selectedOrganizationUuid && !runtimeOrganizationUuid) {
|
||||
return refreshTokenComparison === 'same' || oauthAccountMatches ? 'match' : 'unverifiable'
|
||||
}
|
||||
if (
|
||||
selectedOrganizationUuid &&
|
||||
runtimeOrganizationUuid &&
|
||||
selectedOrganizationUuid !== runtimeOrganizationUuid
|
||||
) {
|
||||
return 'mismatch'
|
||||
}
|
||||
if (!selectedOrganizationUuid && runtimeOrganizationUuid) {
|
||||
return refreshTokenComparison === 'same' ? 'match' : 'unverifiable'
|
||||
}
|
||||
|
||||
return 'match'
|
||||
}
|
||||
|
||||
protected liveRuntimeCredentialsCanUpdateActiveAccount(
|
||||
runtimeCredentialsJson: string,
|
||||
account: ClaudeManagedAccount,
|
||||
managedCredentialsJson: string,
|
||||
managedOauthAccount: unknown
|
||||
): boolean {
|
||||
const match = this.runtimeCredentialsMatchAccount(
|
||||
runtimeCredentialsJson,
|
||||
this.readRuntimeOauthAccount(),
|
||||
account,
|
||||
managedCredentialsJson,
|
||||
managedOauthAccount
|
||||
)
|
||||
if (match === 'match') {
|
||||
return true
|
||||
}
|
||||
const identity = this.readIdentityFromCredentials(runtimeCredentialsJson)
|
||||
const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson)
|
||||
const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
|
||||
const runtimeOauthIdentity = this.readIdentityFromOauthAccount(this.readRuntimeOauthAccount())
|
||||
const selectedOrganizationUuid = this.normalizeField(
|
||||
account.organizationUuid ??
|
||||
managedIdentity?.organizationUuid ??
|
||||
managedOauthIdentity.organizationUuid
|
||||
)
|
||||
return (
|
||||
match === 'unverifiable' &&
|
||||
Boolean(selectedOrganizationUuid) &&
|
||||
(identity?.organizationUuid ?? runtimeOauthIdentity.organizationUuid) ===
|
||||
selectedOrganizationUuid
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { writeFileAtomically } from '../../codex-accounts/fs-utils'
|
||||
import { ClaudeRuntimeAuthState } from './runtime-auth-state'
|
||||
|
||||
export class ClaudeRuntimeAuthFileStorage extends ClaudeRuntimeAuthState {
|
||||
protected writeRuntimeCredentials(contents: string): void {
|
||||
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
|
||||
mkdirSync(dirname(credentialsPath), { recursive: true })
|
||||
// Why: skip unchanged rewrites to dodge Windows EPERM contention (#1507); re-verify the file since another Claude may have rewritten it.
|
||||
if (
|
||||
this.lastWrittenCredentialsJson === contents &&
|
||||
this.fileContentsEqual(credentialsPath, contents)
|
||||
) {
|
||||
this.ensureOwnerOnlyMode(credentialsPath)
|
||||
return
|
||||
}
|
||||
if (this.fileContentsEqual(credentialsPath, contents)) {
|
||||
this.ensureOwnerOnlyMode(credentialsPath)
|
||||
this.lastWrittenCredentialsJson = contents
|
||||
return
|
||||
}
|
||||
writeFileAtomically(credentialsPath, contents, { mode: 0o600 })
|
||||
this.lastWrittenCredentialsJson = contents
|
||||
}
|
||||
|
||||
protected writeJson(targetPath: string, value: unknown): void {
|
||||
const serialized = `${JSON.stringify(value, null, 2)}\n`
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
// Why: same Windows contention reason as writeRuntimeCredentials.
|
||||
if (this.fileContentsEqual(targetPath, serialized)) {
|
||||
return
|
||||
}
|
||||
writeFileAtomically(targetPath, serialized, { mode: 0o600 })
|
||||
}
|
||||
|
||||
protected fileContentsEqual(targetPath: string, contents: string): boolean {
|
||||
try {
|
||||
return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
protected ensureOwnerOnlyMode(targetPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(targetPath, 0o600)
|
||||
} catch {
|
||||
/* Best effort: the next atomic write will set the restrictive mode. */
|
||||
}
|
||||
}
|
||||
|
||||
protected readJsonObject(targetPath: string): Record<string, unknown> | null {
|
||||
if (!existsSync(targetPath)) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Why: invalid config is unknown external state; return null so we don't erase user or Claude-owned settings.
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected getRuntimeMetadataDir(): string {
|
||||
const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth')
|
||||
mkdirSync(metadataDir, { recursive: true })
|
||||
return metadataDir
|
||||
}
|
||||
|
||||
protected getSystemDefaultSnapshotPath(): string {
|
||||
return join(this.getRuntimeMetadataDir(), 'system-default-auth.json')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
readActiveClaudeKeychainCredentials,
|
||||
readActiveClaudeKeychainCredentialsStrict
|
||||
} from '../keychain'
|
||||
import { ClaudeRuntimeAuthManagedCredentials } from './runtime-auth-managed-credentials'
|
||||
import type {
|
||||
ClaudeKeychainReadResult,
|
||||
ClaudeKeychainSnapshotValue,
|
||||
ClaudeSystemDefaultSnapshot
|
||||
} from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManagedCredentials {
|
||||
protected isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot {
|
||||
const snapshot = this.asRecord(value)
|
||||
return (
|
||||
snapshot !== null &&
|
||||
Object.hasOwn(snapshot, 'credentialsJson') &&
|
||||
this.isOptionalNullableString(snapshot.credentialsJson) &&
|
||||
this.isOptionalNullableString(snapshot.keychainCredentialsJson) &&
|
||||
this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) &&
|
||||
this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) &&
|
||||
this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) &&
|
||||
this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) &&
|
||||
this.hasValidKeychainSnapshotValue(snapshot, 'scoped') &&
|
||||
this.hasValidKeychainSnapshotValue(snapshot, 'legacy') &&
|
||||
(snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
protected isOptionalNullableString(value: unknown): boolean {
|
||||
return value === undefined || value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
protected isOptionalBoolean(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
protected snapshotKeychainCredentials(
|
||||
credentialsJson: string | null,
|
||||
previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined,
|
||||
service: 'scoped' | 'legacy',
|
||||
managedCredentialsJson: string | undefined
|
||||
): string | null {
|
||||
if (managedCredentialsJson && credentialsJson === managedCredentialsJson && previousSnapshot) {
|
||||
const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service)
|
||||
if (previousValue.status === 'captured') {
|
||||
return previousValue.credentialsJson
|
||||
}
|
||||
}
|
||||
return credentialsJson
|
||||
}
|
||||
|
||||
protected hasValidKeychainSnapshotValue(
|
||||
snapshot: Record<string, unknown>,
|
||||
service: 'scoped' | 'legacy'
|
||||
): boolean {
|
||||
const capturedKey =
|
||||
service === 'scoped'
|
||||
? 'scopedKeychainCredentialsCaptured'
|
||||
: 'legacyKeychainCredentialsCaptured'
|
||||
if (snapshot[capturedKey] === false) {
|
||||
return true
|
||||
}
|
||||
const credentialsKey =
|
||||
service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson'
|
||||
return (
|
||||
Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson')
|
||||
)
|
||||
}
|
||||
|
||||
protected readKeychainSnapshotValue(
|
||||
snapshot: ClaudeSystemDefaultSnapshot | null,
|
||||
service: 'scoped' | 'legacy'
|
||||
): ClaudeKeychainSnapshotValue {
|
||||
if (!snapshot) {
|
||||
return { status: 'captured', credentialsJson: null }
|
||||
}
|
||||
const capturedKey =
|
||||
service === 'scoped'
|
||||
? 'scopedKeychainCredentialsCaptured'
|
||||
: 'legacyKeychainCredentialsCaptured'
|
||||
if (snapshot[capturedKey] === false) {
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
const credentialsKey =
|
||||
service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson'
|
||||
if (Object.hasOwn(snapshot, credentialsKey)) {
|
||||
return {
|
||||
status: 'captured',
|
||||
credentialsJson: snapshot[credentialsKey] ?? null
|
||||
}
|
||||
}
|
||||
return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson }
|
||||
}
|
||||
|
||||
protected async readAggregateClaudeKeychainCredentialsBestEffort(
|
||||
configDir: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await readActiveClaudeKeychainCredentials(configDir)
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected async readActiveClaudeKeychainCredentialsBestEffort(
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await readActiveClaudeKeychainCredentialsStrict(configDir)
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected async readActiveClaudeKeychainCredentialsForSnapshot(
|
||||
configDir?: string
|
||||
): Promise<ClaudeKeychainReadResult> {
|
||||
try {
|
||||
return {
|
||||
status: 'captured',
|
||||
credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error)
|
||||
return { status: 'failed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../../wsl'
|
||||
import { runWslProcess } from '../../wsl/wsl-runner'
|
||||
import {
|
||||
readClaudeManagedAuthFile,
|
||||
resolveOwnedClaudeManagedAuthPath,
|
||||
writeClaudeManagedAuthFile
|
||||
} from '../managed-auth-path'
|
||||
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from '../oauth-refresh'
|
||||
import {
|
||||
readManagedClaudeKeychainCredentials,
|
||||
writeManagedClaudeKeychainCredentials
|
||||
} from '../keychain'
|
||||
import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity'
|
||||
|
||||
const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout'
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCredentialIdentity {
|
||||
protected async readManagedCredentials(account: ClaudeManagedAccount): Promise<string | null> {
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
return null
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return readManagedClaudeKeychainCredentials(account.id)
|
||||
}
|
||||
return readClaudeManagedAuthFile(managedAuthPath, '.credentials.json')
|
||||
}
|
||||
|
||||
protected async writeManagedCredentials(
|
||||
account: ClaudeManagedAccount,
|
||||
credentialsJson: string
|
||||
): Promise<void> {
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
throw new Error('Managed Claude auth storage is not owned by Orca.')
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
await writeManagedClaudeKeychainCredentials(account.id, credentialsJson)
|
||||
return
|
||||
}
|
||||
writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively refresh an account's OAuth token and persist the rotation to
|
||||
* managed storage. Returns the refreshed credentials JSON, or null when no
|
||||
* refresh happened (token valid, no refresh token, or network failure).
|
||||
*
|
||||
* Caller guarantees this account isn't the live/active one and runs inside the
|
||||
* serialized mutation queue, so the single-use refresh token can't rotate concurrently.
|
||||
*/
|
||||
protected async refreshManagedAccountTokenIfNeeded(
|
||||
account: ClaudeManagedAccount,
|
||||
credentialsJson: string
|
||||
): Promise<string | null> {
|
||||
if (!isOauthTokenExpiring(credentialsJson)) {
|
||||
return null
|
||||
}
|
||||
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
|
||||
if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await this.writeManagedCredentials(account, refreshed)
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error)
|
||||
return null
|
||||
}
|
||||
return refreshed
|
||||
}
|
||||
|
||||
protected async readManagedOauthAccount(account: ClaudeManagedAccount): Promise<unknown> {
|
||||
const managedAuthPath = await this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json')
|
||||
return contents ? (JSON.parse(contents) as unknown) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
protected async getOwnedManagedAuthPath(account: ClaudeManagedAccount): Promise<string | null> {
|
||||
const wslInfo = parseWslUncPath(account.managedAuthPath)
|
||||
if (wslInfo) {
|
||||
if (
|
||||
!wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') ||
|
||||
!wslInfo.linuxPath.endsWith('/auth')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const owned = await runWslProcess({
|
||||
distro: wslInfo.distro,
|
||||
loginPath: 'none',
|
||||
shell: 'bash',
|
||||
script: [
|
||||
'set -euo pipefail',
|
||||
`candidate=${shellQuote(wslInfo.linuxPath)}`,
|
||||
'managed_root="${HOME%/}/.local/share/orca/claude-accounts"',
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-claude-auth"',
|
||||
`test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(account.id)}`,
|
||||
'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
].join('\n'),
|
||||
timeoutMs: 5000
|
||||
})
|
||||
if (owned.timedOut) {
|
||||
throw new Error(OWNERSHIP_PROBE_TIMEOUT)
|
||||
}
|
||||
if (owned.code !== 0) {
|
||||
return null
|
||||
}
|
||||
const canonicalLinuxPath = owned.stdout.trim()
|
||||
return canonicalLinuxPath ? toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) : null
|
||||
} catch (error) {
|
||||
// Why rethrow a timeout: null means "not owned by Orca", and the
|
||||
// caller persists that -- clearing the user's account selection. A
|
||||
// slow distro must not decide ownership. Swallowing it here is what
|
||||
// made the previous guard dead code.
|
||||
if (error instanceof Error && error.message === OWNERSHIP_PROBE_TIMEOUT) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
return existsSync(account.managedAuthPath) ? account.managedAuthPath : null
|
||||
}
|
||||
return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, {
|
||||
adoptLegacyMarker: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { join } from 'node:path'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { getDefaultWslDistro, getWslHome } from '../../wsl'
|
||||
import {
|
||||
getSelectedClaudeAccountIdForTarget,
|
||||
normalizeClaudeAccountSelectionTarget,
|
||||
type ClaudeAccountSelectionTarget
|
||||
} from '../runtime-selection'
|
||||
import { ClaudeRuntimeAuthSnapshotRestore } from './runtime-auth-snapshot-restore'
|
||||
import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapshotRestore {
|
||||
protected getPreparation(target?: ClaudeAccountSelectionTarget): ClaudeRuntimeAuthPreparation {
|
||||
const settings = this.store.getSettings()
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const normalizedTarget = this.resolveWslDefaultTarget(
|
||||
target ?? this.getDefaultAccountSelectionTarget(settings)
|
||||
)
|
||||
const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget)
|
||||
const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId)
|
||||
if (
|
||||
normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl' &&
|
||||
activeAccount?.managedAuthRuntime === 'wsl' &&
|
||||
activeAccount.wslLinuxAuthPath
|
||||
) {
|
||||
return {
|
||||
configDir: activeAccount.managedAuthPath,
|
||||
runtime: 'wsl',
|
||||
wslDistro: activeAccount.wslDistro ?? null,
|
||||
wslLinuxConfigDir: activeAccount.wslLinuxAuthPath,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: activeAccount.wslLinuxAuthPath },
|
||||
stripAuthEnv: true,
|
||||
provenance: `managed:${activeAccount.id}:wsl:${activeAccount.wslDistro ?? ''}`
|
||||
}
|
||||
}
|
||||
if (normalizeClaudeAccountSelectionTarget(normalizedTarget).runtime === 'wsl') {
|
||||
const distro =
|
||||
normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? getDefaultWslDistro()
|
||||
const wslHome = distro ? getWslHome(distro) : null
|
||||
const wslHomeInfo = wslHome ? parseWslUncPath(wslHome) : null
|
||||
if (distro && wslHome && wslHomeInfo) {
|
||||
const windowsConfigDir = join(wslHome, '.claude')
|
||||
const linuxConfigDir = `${wslHomeInfo.linuxPath.replace(/\/$/, '')}/.claude`
|
||||
return {
|
||||
configDir: windowsConfigDir,
|
||||
runtime: 'wsl',
|
||||
wslDistro: distro,
|
||||
wslLinuxConfigDir: linuxConfigDir,
|
||||
envPatch: {},
|
||||
stripAuthEnv: true,
|
||||
provenance: `wsl:${distro}:system`
|
||||
}
|
||||
}
|
||||
return {
|
||||
configDir: paths.configDir,
|
||||
runtime: 'wsl',
|
||||
wslDistro: normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: {},
|
||||
stripAuthEnv: true,
|
||||
provenance: `wsl:${normalizeClaudeAccountSelectionTarget(normalizedTarget).wslDistro ?? '__default__'}:system`
|
||||
}
|
||||
}
|
||||
return {
|
||||
configDir: paths.configDir,
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: paths.envPatch,
|
||||
stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'),
|
||||
managedRefreshDeferredByLivePty: Boolean(
|
||||
activeAccountId &&
|
||||
activeAccount?.managedAuthRuntime !== 'wsl' &&
|
||||
this.managedRefreshDeferredByLivePtyAccountId === activeAccountId
|
||||
),
|
||||
provenance:
|
||||
activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'
|
||||
? `managed:${activeAccountId}`
|
||||
: 'system'
|
||||
}
|
||||
}
|
||||
|
||||
protected getActiveAccount(
|
||||
accounts: ClaudeManagedAccount[],
|
||||
activeAccountId: string | null
|
||||
): ClaudeManagedAccount | null {
|
||||
if (!activeAccountId) {
|
||||
return null
|
||||
}
|
||||
return accounts.find((account) => account.id === activeAccountId) ?? null
|
||||
}
|
||||
|
||||
protected getDefaultAccountSelectionTarget(
|
||||
settings = this.store.getSettings()
|
||||
): ClaudeAccountSelectionTarget {
|
||||
// Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host.
|
||||
const resolved = resolveLocalAccountRuntimeTarget(settings)
|
||||
if (process.platform === 'win32' && resolved.runtime === 'wsl') {
|
||||
return { runtime: 'wsl', wslDistro: resolved.wslDistro }
|
||||
}
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
|
||||
protected resolveWslDefaultTarget(
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
): ClaudeAccountSelectionTarget {
|
||||
if (target?.runtime !== 'wsl' || target.wslDistro?.trim()) {
|
||||
return target ?? { runtime: 'host' }
|
||||
}
|
||||
const defaultDistro = getDefaultWslDistro()
|
||||
return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain'
|
||||
import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching'
|
||||
import type {
|
||||
ClaudeReadBackMatch,
|
||||
ClaudeReadBackResult,
|
||||
ClaudeRuntimeCredentialCandidate
|
||||
} from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatching {
|
||||
protected async readBackRefreshedTokens(
|
||||
baselineCredentialsJson: string,
|
||||
options: { updateLastWrittenCredentialsJson: boolean }
|
||||
): Promise<ClaudeReadBackResult> {
|
||||
try {
|
||||
const candidates =
|
||||
await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson)
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'unchanged' }
|
||||
}
|
||||
const changedCandidates =
|
||||
this.lastWrittenCredentialsJson === null
|
||||
? candidates
|
||||
: candidates.filter(
|
||||
(candidate) => candidate.credentialsJson !== this.lastWrittenCredentialsJson
|
||||
)
|
||||
if (changedCandidates.length === 0) {
|
||||
return { status: 'unchanged' }
|
||||
}
|
||||
|
||||
const acceptedCandidates: {
|
||||
credentialsJson: string
|
||||
match: Extract<ClaudeReadBackMatch, { kind: 'matched' }>
|
||||
}[] = []
|
||||
const ambiguousCandidates: string[] = []
|
||||
let sawAmbiguousCandidate = false
|
||||
let sawValidChangedCandidate = false
|
||||
for (const runtimeContents of changedCandidates) {
|
||||
if (!this.isValidCredentialsJsonObject(runtimeContents.credentialsJson)) {
|
||||
continue
|
||||
}
|
||||
sawValidChangedCandidate = true
|
||||
const match = await this.findManagedAccountForRuntimeCredentials(
|
||||
runtimeContents.credentialsJson,
|
||||
runtimeContents.runtimeOauthAccount
|
||||
)
|
||||
if (match.kind === 'ambiguous') {
|
||||
sawAmbiguousCandidate = true
|
||||
ambiguousCandidates.push(runtimeContents.credentialsJson)
|
||||
continue
|
||||
}
|
||||
if (match.kind !== 'matched') {
|
||||
continue
|
||||
}
|
||||
// Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed.
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
const fresher = this.runtimeCredentialsAreFresher(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
const refreshTokenRotated =
|
||||
this.compareRefreshTokens(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
) === 'different'
|
||||
const older = this.runtimeCredentialsAreOlder(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
if (!fresher && !(refreshTokenRotated && !older)) {
|
||||
continue
|
||||
}
|
||||
} else if (
|
||||
this.runtimeCredentialsAreOlder(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
acceptedCandidates.push({ credentialsJson: runtimeContents.credentialsJson, match })
|
||||
}
|
||||
if (acceptedCandidates.length === 0) {
|
||||
if (sawAmbiguousCandidate) {
|
||||
console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back')
|
||||
}
|
||||
return {
|
||||
status: 'rejected',
|
||||
runtimeCredentialsChanged: true,
|
||||
hasValidChangedRuntimeCredentials: sawValidChangedCandidate,
|
||||
runtimeCredentialsJson:
|
||||
ambiguousCandidates.length === 1 ? ambiguousCandidates[0] : undefined
|
||||
}
|
||||
}
|
||||
const { credentialsJson: runtimeContents, match } =
|
||||
this.chooseFreshestReadBackCandidate(acceptedCandidates)
|
||||
|
||||
await this.writeManagedCredentials(match.account, runtimeContents)
|
||||
if (options.updateLastWrittenCredentialsJson) {
|
||||
this.writeRuntimeCredentials(runtimeContents)
|
||||
this.lastWrittenCredentialsJson = runtimeContents
|
||||
if (process.platform === 'darwin') {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir)
|
||||
}
|
||||
}
|
||||
return { status: 'persisted' }
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort; a transient fs error must not block forward sync (worst case: one more stale-token cycle).
|
||||
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
|
||||
return {
|
||||
status: 'rejected',
|
||||
runtimeCredentialsChanged:
|
||||
this.runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson),
|
||||
// Why: an fs error hides whether a live session's refresh is present, so err toward preserving runtime state.
|
||||
hasValidChangedRuntimeCredentials: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async readRuntimeCredentialCandidatesForReadBack(
|
||||
baselineCredentialsJson: string
|
||||
): Promise<ClaudeRuntimeCredentialCandidate[]> {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const fileCredentials = existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
const runtimeOauthAccount = this.readRuntimeOauthAccount()
|
||||
const candidates: ClaudeRuntimeCredentialCandidate[] = []
|
||||
const pushCandidate = (credentialsJson: string | null): void => {
|
||||
if (
|
||||
credentialsJson &&
|
||||
!candidates.some((candidate) => candidate.credentialsJson === credentialsJson)
|
||||
) {
|
||||
candidates.push({ credentialsJson, runtimeOauthAccount })
|
||||
}
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort(
|
||||
paths.configDir
|
||||
)
|
||||
const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort()
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
pushCandidate(scopedKeychainCredentials)
|
||||
pushCandidate(legacyKeychainCredentials)
|
||||
pushCandidate(fileCredentials)
|
||||
return candidates.filter(
|
||||
(candidate) => candidate.credentialsJson !== baselineCredentialsJson
|
||||
)
|
||||
}
|
||||
pushCandidate(scopedKeychainCredentials)
|
||||
pushCandidate(legacyKeychainCredentials)
|
||||
}
|
||||
pushCandidate(fileCredentials)
|
||||
return candidates
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import {
|
||||
deleteActiveClaudeKeychainCredentialsStrict,
|
||||
writeActiveClaudeKeychainCredentials
|
||||
} from '../keychain'
|
||||
import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots'
|
||||
import {
|
||||
RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR,
|
||||
type ClaudeKeychainSnapshotValue
|
||||
} from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots {
|
||||
protected readRuntimeCredentialsFile(): string | null {
|
||||
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
|
||||
return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null
|
||||
}
|
||||
|
||||
protected runtimeCredentialsBelongToAccount(
|
||||
credentialsJson: string | null,
|
||||
account: ClaudeManagedAccount,
|
||||
managedOauthAccount: unknown
|
||||
): boolean {
|
||||
if (!credentialsJson) {
|
||||
return false
|
||||
}
|
||||
const identity = this.readIdentityFromCredentials(credentialsJson)
|
||||
if (
|
||||
!identity?.email ||
|
||||
(account.email && this.normalizeField(account.email) !== identity.email)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
|
||||
const selectedOrganizationUuid = this.normalizeField(
|
||||
account.organizationUuid ?? oauthIdentity.organizationUuid
|
||||
)
|
||||
if (selectedOrganizationUuid) {
|
||||
return identity.organizationUuid === selectedOrganizationUuid
|
||||
}
|
||||
return !identity.organizationUuid
|
||||
}
|
||||
|
||||
protected clearLastWrittenRuntimeState(): void {
|
||||
this.lastWrittenCredentialsJson = null
|
||||
this.lastWrittenOauthAccount = null
|
||||
this.hasLastWrittenOauthAccount = false
|
||||
this.hasMaterializedRuntimeAuth = false
|
||||
}
|
||||
|
||||
protected hasUnchangedRuntimeCredentials(
|
||||
previouslyWrittenCredentialsJson: string | null
|
||||
): boolean {
|
||||
if (previouslyWrittenCredentialsJson === null) {
|
||||
return false
|
||||
}
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const currentCredentialsJson = existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
return currentCredentialsJson === previouslyWrittenCredentialsJson
|
||||
}
|
||||
|
||||
protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
try {
|
||||
const currentCredentialsJson = existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
return (
|
||||
currentCredentialsJson !== null &&
|
||||
currentCredentialsJson !== (this.lastWrittenCredentialsJson ?? baselineCredentialsJson)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
protected restoreRuntimeCredentials(credentialsJson: string | null): void {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
if (credentialsJson !== null) {
|
||||
this.writeRuntimeCredentials(credentialsJson)
|
||||
} else {
|
||||
rmSync(paths.credentialsPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
protected restoreRuntimeOauthAccountIfOwned(
|
||||
oauthAccount: unknown,
|
||||
ownedOauthAccount: unknown,
|
||||
options: { allowCredentialSurfaceOwnership: boolean }
|
||||
): void {
|
||||
const currentOauthAccount = this.readRuntimeOauthAccount()
|
||||
if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) {
|
||||
return
|
||||
}
|
||||
if (options.allowCredentialSurfaceOwnership) {
|
||||
this.writeRuntimeOauthAccount(oauthAccount)
|
||||
return
|
||||
}
|
||||
if (
|
||||
(ownedOauthAccount === null || ownedOauthAccount === undefined) &&
|
||||
!options.allowCredentialSurfaceOwnership
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) {
|
||||
return
|
||||
}
|
||||
this.writeRuntimeOauthAccount(oauthAccount)
|
||||
}
|
||||
|
||||
protected async hasUnchangedActiveClaudeKeychainCredentials(
|
||||
snapshotValue: ClaudeKeychainSnapshotValue,
|
||||
previouslyWrittenCredentialsJson: string | null,
|
||||
configDir?: string
|
||||
): Promise<boolean> {
|
||||
if (snapshotValue.status === 'unknown') {
|
||||
return false
|
||||
}
|
||||
const currentCredentialsJson =
|
||||
await this.readActiveClaudeKeychainCredentialsBestEffort(configDir)
|
||||
return (
|
||||
previouslyWrittenCredentialsJson !== null &&
|
||||
currentCredentialsJson === previouslyWrittenCredentialsJson
|
||||
)
|
||||
}
|
||||
|
||||
protected async restoreActiveClaudeKeychainCredentials(
|
||||
credentialsJson: string | null,
|
||||
configDir?: string
|
||||
): Promise<void> {
|
||||
await (credentialsJson !== null
|
||||
? writeActiveClaudeKeychainCredentials(credentialsJson, configDir)
|
||||
: deleteActiveClaudeKeychainCredentialsStrict(configDir))
|
||||
}
|
||||
|
||||
protected async hasActiveKeychainCredentialsForAccount(
|
||||
account: ClaudeManagedAccount,
|
||||
managedOauthAccount: unknown,
|
||||
configDir?: string
|
||||
): Promise<boolean> {
|
||||
const currentCredentialsJson =
|
||||
await this.readActiveClaudeKeychainCredentialsBestEffort(configDir)
|
||||
return this.runtimeCredentialsBelongToAccount(
|
||||
currentCredentialsJson,
|
||||
account,
|
||||
managedOauthAccount
|
||||
)
|
||||
}
|
||||
|
||||
protected readRuntimeOauthAccount(): unknown {
|
||||
const configPath = this.pathResolver.getRuntimePaths().configPath
|
||||
if (!existsSync(configPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown
|
||||
const record = this.asRecord(parsed)
|
||||
if (!record) {
|
||||
return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR
|
||||
}
|
||||
return record.oauthAccount ?? null
|
||||
} catch {
|
||||
return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
protected runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean {
|
||||
if (managedOauthAccount === null || managedOauthAccount === undefined) {
|
||||
return false
|
||||
}
|
||||
const currentOauthAccount = this.readRuntimeOauthAccount()
|
||||
if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) {
|
||||
return false
|
||||
}
|
||||
return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount)
|
||||
}
|
||||
|
||||
protected writeRuntimeOauthAccount(oauthAccount: unknown): boolean {
|
||||
const configPath = this.pathResolver.getRuntimePaths().configPath
|
||||
const existing = this.readJsonObject(configPath)
|
||||
if (existing === null) {
|
||||
return false
|
||||
}
|
||||
if (oauthAccount === null || oauthAccount === undefined) {
|
||||
delete existing.oauthAccount
|
||||
} else {
|
||||
existing.oauthAccount = oauthAccount
|
||||
}
|
||||
this.writeJson(configPath, existing)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { ClaudeRuntimeAuthReadback } from './runtime-auth-readback'
|
||||
import {
|
||||
RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR,
|
||||
type ClaudeSystemDefaultSnapshot
|
||||
} from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback {
|
||||
protected async captureSystemDefaultSnapshotForManagedEntry(
|
||||
runtimeCredentialsJson: string | null,
|
||||
managedCredentialsJson: string
|
||||
): Promise<void> {
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath)
|
||||
if (runtimeCredentialsJson !== managedCredentialsJson) {
|
||||
await this.captureSystemDefaultSnapshot({
|
||||
force: true,
|
||||
previousSnapshot: existingSnapshot,
|
||||
managedCredentialsJson
|
||||
})
|
||||
return
|
||||
}
|
||||
if (existingSnapshot) {
|
||||
await this.captureSystemDefaultSnapshot({
|
||||
force: true,
|
||||
credentialsJsonOverride: existingSnapshot.credentialsJson,
|
||||
previousSnapshot: existingSnapshot,
|
||||
managedCredentialsJson
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.captureSystemDefaultSnapshot({ force: false })
|
||||
}
|
||||
|
||||
protected async captureSystemDefaultSnapshot(options: {
|
||||
force: boolean
|
||||
credentialsJsonOverride?: string | null
|
||||
previousSnapshot?: ClaudeSystemDefaultSnapshot | null
|
||||
managedCredentialsJson?: string
|
||||
}): Promise<void> {
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (!options.force && existsSync(snapshotPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const credentialsJson =
|
||||
options.credentialsJsonOverride !== undefined
|
||||
? options.credentialsJsonOverride
|
||||
: existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort(
|
||||
paths.configDir
|
||||
)
|
||||
const scopedKeychainCredentials =
|
||||
process.platform === 'darwin'
|
||||
? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir)
|
||||
: ({ status: 'captured', credentialsJson: null } as const)
|
||||
const legacyKeychainCredentialsJson =
|
||||
process.platform === 'darwin'
|
||||
? await this.readActiveClaudeKeychainCredentialsForSnapshot()
|
||||
: ({ status: 'captured', credentialsJson: null } as const)
|
||||
if (
|
||||
scopedKeychainCredentials.status === 'failed' ||
|
||||
legacyKeychainCredentialsJson.status === 'failed'
|
||||
) {
|
||||
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 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',
|
||||
capturedAt: Date.now()
|
||||
}
|
||||
this.writeJson(snapshotPath, snapshot)
|
||||
}
|
||||
|
||||
protected readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null {
|
||||
if (!existsSync(snapshotPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown
|
||||
if (this.isSystemDefaultSnapshot(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
throw new Error('Invalid Claude system-default auth snapshot shape')
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error)
|
||||
rmSync(snapshotPath, { force: true })
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import { deleteActiveClaudeKeychainCredentialsStrict } from '../keychain'
|
||||
import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture'
|
||||
import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types'
|
||||
|
||||
export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotCapture {
|
||||
protected async restoreSystemDefaultSnapshot(
|
||||
ownedCredentialsJson?: string | null,
|
||||
ownedOauthAccount?: unknown
|
||||
): Promise<void> {
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const previouslyWrittenCredentialsJson =
|
||||
this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null
|
||||
const snapshot = this.readSystemDefaultSnapshot(snapshotPath)
|
||||
|
||||
const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials(
|
||||
previouslyWrittenCredentialsJson
|
||||
)
|
||||
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
|
||||
let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null
|
||||
let legacySnapshot: ClaudeKeychainSnapshotValue | null = null
|
||||
let scopedKeychainOwned = false
|
||||
let legacyKeychainOwned = false
|
||||
if (process.platform === 'darwin') {
|
||||
scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped')
|
||||
legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy')
|
||||
scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials(
|
||||
scopedSnapshot,
|
||||
previouslyWrittenCredentialsJson,
|
||||
paths.configDir
|
||||
)
|
||||
legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials(
|
||||
legacySnapshot,
|
||||
previouslyWrittenCredentialsJson
|
||||
)
|
||||
hasCredentialSurfaceOwnership =
|
||||
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
|
||||
}
|
||||
this.restoreRuntimeOauthAccountIfOwned(
|
||||
snapshot?.configOauthAccount ?? null,
|
||||
this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership),
|
||||
{ allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership }
|
||||
)
|
||||
if (fileCredentialsOwned) {
|
||||
this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) {
|
||||
await this.restoreActiveClaudeKeychainCredentials(
|
||||
scopedSnapshot.credentialsJson,
|
||||
paths.configDir
|
||||
)
|
||||
}
|
||||
if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) {
|
||||
await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson)
|
||||
}
|
||||
}
|
||||
this.lastWrittenCredentialsJson = null
|
||||
this.lastWrittenOauthAccount = null
|
||||
this.hasLastWrittenOauthAccount = false
|
||||
this.hasMaterializedRuntimeAuth = false
|
||||
}
|
||||
|
||||
protected getOwnedRuntimeOauthBaseline(
|
||||
ownedOauthAccount: unknown,
|
||||
hasCredentialSurfaceOwnership: boolean
|
||||
): unknown {
|
||||
if (this.hasLastWrittenOauthAccount) {
|
||||
return this.lastWrittenOauthAccount
|
||||
}
|
||||
// Why: managed metadata hints identity but isn't proof Orca wrote .claude.json; use only after a credential surface proves ownership.
|
||||
if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) {
|
||||
return ownedOauthAccount
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected async clearRuntimeAuthForAccount(
|
||||
account: ClaudeManagedAccount,
|
||||
managedOauthAccount: unknown
|
||||
): Promise<void> {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount(
|
||||
this.readRuntimeCredentialsFile(),
|
||||
account,
|
||||
managedOauthAccount
|
||||
)
|
||||
let scopedKeychainOwned = false
|
||||
let legacyKeychainOwned = false
|
||||
if (process.platform === 'darwin') {
|
||||
scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount(
|
||||
account,
|
||||
managedOauthAccount,
|
||||
paths.configDir
|
||||
)
|
||||
legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount(
|
||||
account,
|
||||
managedOauthAccount
|
||||
)
|
||||
}
|
||||
const hasCredentialSurfaceOwnership =
|
||||
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
|
||||
this.restoreRuntimeOauthAccountIfOwned(
|
||||
null,
|
||||
this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership),
|
||||
{
|
||||
allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership
|
||||
}
|
||||
)
|
||||
if (fileCredentialsOwned) {
|
||||
rmSync(paths.credentialsPath, { force: true })
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
if (scopedKeychainOwned) {
|
||||
await deleteActiveClaudeKeychainCredentialsStrict(paths.configDir)
|
||||
}
|
||||
if (legacyKeychainOwned) {
|
||||
await deleteActiveClaudeKeychainCredentialsStrict()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async restoreSystemDefaultSnapshotForMissingManagedCredentials(
|
||||
account: ClaudeManagedAccount,
|
||||
managedOauthAccount: unknown
|
||||
): Promise<void> {
|
||||
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
|
||||
if (!snapshot) {
|
||||
await this.clearRuntimeAuthForAccount(account, managedOauthAccount)
|
||||
this.clearLastWrittenRuntimeState()
|
||||
return
|
||||
}
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount(
|
||||
this.readRuntimeCredentialsFile(),
|
||||
account,
|
||||
managedOauthAccount
|
||||
)
|
||||
let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null
|
||||
let legacySnapshot: ClaudeKeychainSnapshotValue | null = null
|
||||
let scopedKeychainOwned = false
|
||||
let legacyKeychainOwned = false
|
||||
if (process.platform === 'darwin') {
|
||||
scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped')
|
||||
legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy')
|
||||
scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount(
|
||||
account,
|
||||
managedOauthAccount,
|
||||
paths.configDir
|
||||
)
|
||||
legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount(
|
||||
account,
|
||||
managedOauthAccount
|
||||
)
|
||||
}
|
||||
const hasCredentialSurfaceOwnership =
|
||||
fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned
|
||||
this.restoreRuntimeOauthAccountIfOwned(
|
||||
snapshot.configOauthAccount,
|
||||
this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership),
|
||||
{
|
||||
allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership
|
||||
}
|
||||
)
|
||||
if (fileCredentialsOwned) {
|
||||
this.restoreRuntimeCredentials(snapshot.credentialsJson)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) {
|
||||
await this.restoreActiveClaudeKeychainCredentials(
|
||||
scopedSnapshot.credentialsJson,
|
||||
paths.configDir
|
||||
)
|
||||
}
|
||||
if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) {
|
||||
await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson)
|
||||
}
|
||||
}
|
||||
this.clearLastWrittenRuntimeState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Store } from '../../persistence'
|
||||
import { ClaudeRuntimePathResolver } from '../runtime-paths'
|
||||
|
||||
export class ClaudeRuntimeAuthState {
|
||||
protected readonly pathResolver = new ClaudeRuntimePathResolver()
|
||||
protected mutationQueue: Promise<unknown> = Promise.resolve()
|
||||
protected lastSyncedAccountId: string | null = null
|
||||
// Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default.
|
||||
protected lastWrittenCredentialsJson: string | null = null
|
||||
protected hasMaterializedRuntimeAuth = false
|
||||
protected hasLastWrittenOauthAccount = false
|
||||
protected lastWrittenOauthAccount: unknown = null
|
||||
protected skipNextReadBackForAccountId: string | null = null
|
||||
protected managedRefreshDeferredByLivePtyAccountId: string | null = null
|
||||
|
||||
protected constructor(protected readonly store: Store) {}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import {
|
||||
getSelectedClaudeAccountIdForTarget,
|
||||
normalizeClaudeAccountSelectionTarget,
|
||||
normalizeClaudeRuntimeSelection,
|
||||
setSelectedClaudeAccountIdForTarget,
|
||||
type ClaudeAccountSelectionTarget
|
||||
} 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 {
|
||||
protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise<void> {
|
||||
const settings = this.store.getSettings()
|
||||
const effectiveTarget = this.resolveWslDefaultTarget(target)
|
||||
const normalizedTarget = normalizeClaudeAccountSelectionTarget(effectiveTarget)
|
||||
const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget)
|
||||
const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId)
|
||||
const previousAccount = this.getActiveAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
this.lastSyncedAccountId
|
||||
)
|
||||
this.managedRefreshDeferredByLivePtyAccountId = null
|
||||
const previousManagedCredentialsJson = previousAccount
|
||||
? await this.readManagedCredentials(previousAccount)
|
||||
: null
|
||||
const previousManagedOauthAccount = previousAccount
|
||||
? await this.readManagedOauthAccount(previousAccount)
|
||||
: null
|
||||
if (previousAccount && previousAccount.id !== activeAccount?.id) {
|
||||
if (previousManagedCredentialsJson) {
|
||||
const outgoingReadBackResult = await this.readBackRefreshedTokens(
|
||||
previousManagedCredentialsJson,
|
||||
{
|
||||
updateLastWrittenCredentialsJson: true
|
||||
}
|
||||
)
|
||||
if (
|
||||
outgoingReadBackResult.status === 'rejected' &&
|
||||
outgoingReadBackResult.runtimeCredentialsChanged &&
|
||||
hasLiveClaudePtys()
|
||||
) {
|
||||
if (
|
||||
outgoingReadBackResult.runtimeCredentialsJson &&
|
||||
this.liveRuntimeCredentialsCanUpdateActiveAccount(
|
||||
outgoingReadBackResult.runtimeCredentialsJson,
|
||||
previousAccount,
|
||||
previousManagedCredentialsJson,
|
||||
previousManagedOauthAccount
|
||||
)
|
||||
) {
|
||||
// Why: switching away while Claude is live must preserve verified token refreshes before replacing shared runtime credentials.
|
||||
await this.writeManagedCredentials(
|
||||
previousAccount,
|
||||
outgoingReadBackResult.runtimeCredentialsJson
|
||||
)
|
||||
} else {
|
||||
// Why: the runtime blob may lack identity proof for a live-session refresh; skip persisting it, but still let new terminals move to the account.
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!activeAccount) {
|
||||
if (activeAccountId) {
|
||||
const nextSelection = setSelectedClaudeAccountIdForTarget(
|
||||
normalizeClaudeRuntimeSelection(settings),
|
||||
null,
|
||||
normalizedTarget
|
||||
)
|
||||
this.store.updateSettings({
|
||||
activeClaudeManagedAccountId:
|
||||
normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId,
|
||||
activeClaudeManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
}
|
||||
if (normalizedTarget.runtime === 'wsl') {
|
||||
return
|
||||
}
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
await (previousAccount
|
||||
? this.restoreSystemDefaultSnapshot(
|
||||
previousManagedCredentialsJson,
|
||||
previousManagedOauthAccount
|
||||
)
|
||||
: this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined))
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeAccount.managedAuthRuntime === 'wsl') {
|
||||
if (!(await this.getOwnedManagedAuthPath(activeAccount))) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active WSL managed account is not owned by Orca, restoring system default'
|
||||
)
|
||||
const nextSelection = setSelectedClaudeAccountIdForTarget(
|
||||
normalizeClaudeRuntimeSelection(settings),
|
||||
null,
|
||||
normalizedTarget
|
||||
)
|
||||
this.store.updateSettings({
|
||||
activeClaudeManagedAccountId:
|
||||
normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId,
|
||||
activeClaudeManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
return
|
||||
}
|
||||
const credentialsJson = await this.readManagedCredentials(activeAccount)
|
||||
if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active WSL managed account is missing or has invalid credentials, restoring system default'
|
||||
)
|
||||
const nextSelection = setSelectedClaudeAccountIdForTarget(
|
||||
normalizeClaudeRuntimeSelection(settings),
|
||||
null,
|
||||
normalizedTarget
|
||||
)
|
||||
this.store.updateSettings({
|
||||
activeClaudeManagedAccountId:
|
||||
normalizedTarget.runtime === 'host' ? null : settings.activeClaudeManagedAccountId,
|
||||
activeClaudeManagedAccountIdsByRuntime: nextSelection
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: WSL managed accounts are isolated by their Linux CLAUDE_CONFIG_DIR; materializing into Windows ~/.claude would mix two auth stores.
|
||||
this.clearLastWrittenRuntimeState()
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await this.getOwnedManagedAuthPath(activeAccount))) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default'
|
||||
)
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
if (
|
||||
previousAccount &&
|
||||
(previousAccount.id !== activeAccount.id ||
|
||||
this.hasMaterializedRuntimeAuth ||
|
||||
this.runtimeOauthAccountMatches(await this.readManagedOauthAccount(previousAccount)))
|
||||
) {
|
||||
await this.restoreSystemDefaultSnapshotForMissingManagedCredentials(
|
||||
previousAccount,
|
||||
previousManagedOauthAccount
|
||||
)
|
||||
} else if (!previousAccount && this.hasMaterializedRuntimeAuth) {
|
||||
await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)
|
||||
}
|
||||
}
|
||||
this.store.updateSettings({ activeClaudeManagedAccountId: null })
|
||||
this.lastSyncedAccountId = null
|
||||
return
|
||||
}
|
||||
|
||||
let credentialsJson = await this.readManagedCredentials(activeAccount)
|
||||
if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default'
|
||||
)
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
if (
|
||||
previousAccount &&
|
||||
(previousAccount.id !== activeAccount.id ||
|
||||
this.hasMaterializedRuntimeAuth ||
|
||||
this.runtimeOauthAccountMatches(previousManagedOauthAccount))
|
||||
) {
|
||||
await this.restoreSystemDefaultSnapshotForMissingManagedCredentials(
|
||||
previousAccount,
|
||||
previousManagedOauthAccount
|
||||
)
|
||||
} else if (!previousAccount && this.hasMaterializedRuntimeAuth) {
|
||||
await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)
|
||||
}
|
||||
}
|
||||
this.store.updateSettings({ activeClaudeManagedAccountId: null })
|
||||
this.lastSyncedAccountId = null
|
||||
return
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const runtimeCredentialsJson = existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
await this.captureSystemDefaultSnapshotForManagedEntry(
|
||||
runtimeCredentialsJson,
|
||||
credentialsJson
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the CLI writes refreshed tokens to .credentials.json; if runtime differs from our last write, preserve them to managed storage before overwriting.
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
if (this.skipNextReadBackForAccountId === activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
} else {
|
||||
const readBackResult = await this.readBackRefreshedTokens(credentialsJson, {
|
||||
updateLastWrittenCredentialsJson: true
|
||||
})
|
||||
if (readBackResult.status === 'persisted') {
|
||||
const updatedCredentialsJson = await this.readManagedCredentials(activeAccount)
|
||||
if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) {
|
||||
credentialsJson = updatedCredentialsJson
|
||||
}
|
||||
} else if (
|
||||
readBackResult.status === 'rejected' &&
|
||||
readBackResult.runtimeCredentialsChanged &&
|
||||
// Why: a live Claude that lost a refresh race can wipe its runtime blob (empty tokens); preserving that would log out every new session.
|
||||
readBackResult.hasValidChangedRuntimeCredentials &&
|
||||
hasLiveClaudePtys()
|
||||
) {
|
||||
if (
|
||||
readBackResult.runtimeCredentialsJson &&
|
||||
this.liveRuntimeCredentialsCanUpdateActiveAccount(
|
||||
readBackResult.runtimeCredentialsJson,
|
||||
activeAccount,
|
||||
credentialsJson,
|
||||
await this.readManagedOauthAccount(activeAccount)
|
||||
)
|
||||
) {
|
||||
// Why: this Claude launched under the active managed account, but persistence still needs positive account proof.
|
||||
await this.writeManagedCredentials(activeAccount, readBackResult.runtimeCredentialsJson)
|
||||
credentialsJson = readBackResult.runtimeCredentialsJson
|
||||
} else {
|
||||
// Why: while Claude runs, an unknown refresh may belong to a live session; rewriting stale managed auth logs it out.
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Preserving changed Claude runtime credentials while live Claude terminals are running'
|
||||
)
|
||||
this.lastSyncedAccountId = activeAccount.id
|
||||
this.hasMaterializedRuntimeAuth = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId !== activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
}
|
||||
|
||||
// Why: rotate+persist the single-use token to managed storage before materializing (else runtime gets a stale token that fails invalid_grant); skip while a live PTY owns the creds since refreshing would double-rotate it (invalidating one copy) — read-back preserves its refresh instead.
|
||||
const liveClaudePtys = hasLiveClaudePtys()
|
||||
if (liveClaudePtys && isOauthTokenExpiring(credentialsJson)) {
|
||||
this.managedRefreshDeferredByLivePtyAccountId = activeAccount.id
|
||||
}
|
||||
if (!liveClaudePtys) {
|
||||
const refreshed = await this.refreshManagedAccountTokenIfNeeded(
|
||||
activeAccount,
|
||||
credentialsJson
|
||||
)
|
||||
if (refreshed) {
|
||||
credentialsJson = refreshed
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
const managedOauthAccount = await this.readManagedOauthAccount(activeAccount)
|
||||
if (this.writeRuntimeOauthAccount(managedOauthAccount)) {
|
||||
this.lastWrittenOauthAccount = managedOauthAccount
|
||||
this.hasLastWrittenOauthAccount = true
|
||||
} else {
|
||||
this.lastWrittenOauthAccount = null
|
||||
this.hasLastWrittenOauthAccount = false
|
||||
}
|
||||
this.lastSyncedAccountId = activeAccount.id
|
||||
this.hasMaterializedRuntimeAuth = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
|
||||
import type { ClaudeEnvPatch } from '../environment'
|
||||
|
||||
export type ClaudeRuntimeAuthPreparation = {
|
||||
configDir: string
|
||||
runtime?: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
wslLinuxConfigDir?: string | null
|
||||
envPatch: ClaudeEnvPatch
|
||||
stripAuthEnv: boolean
|
||||
managedRefreshDeferredByLivePty?: boolean
|
||||
provenance: string
|
||||
}
|
||||
|
||||
export type ClaudeSystemDefaultSnapshot = {
|
||||
credentialsJson: string | null
|
||||
configOauthAccount: unknown
|
||||
keychainCredentialsJson: string | null
|
||||
scopedKeychainCredentialsJson?: string | null
|
||||
legacyKeychainCredentialsJson?: string | null
|
||||
scopedKeychainCredentialsCaptured?: boolean
|
||||
legacyKeychainCredentialsCaptured?: boolean
|
||||
capturedAt: number
|
||||
}
|
||||
|
||||
export type ClaudeAuthIdentity = {
|
||||
accountUuid: string | null
|
||||
email: string | null
|
||||
organizationUuid: string | null
|
||||
}
|
||||
|
||||
export type ClaudeReadBackResult =
|
||||
| { status: 'unchanged' | 'persisted' }
|
||||
| {
|
||||
status: 'rejected'
|
||||
runtimeCredentialsChanged: boolean
|
||||
hasValidChangedRuntimeCredentials: boolean
|
||||
runtimeCredentialsJson?: string
|
||||
}
|
||||
export type ClaudeReadBackMatch =
|
||||
| { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string }
|
||||
| { kind: 'none' | 'ambiguous' }
|
||||
export type ClaudeKeychainReadResult =
|
||||
| { status: 'captured'; credentialsJson: string | null }
|
||||
| { status: 'failed' }
|
||||
export type ClaudeKeychainSnapshotValue =
|
||||
| { status: 'captured'; credentialsJson: string | null }
|
||||
| { status: 'unknown' }
|
||||
export type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing'
|
||||
export type ClaudeRuntimeCredentialCandidate = {
|
||||
credentialsJson: string
|
||||
runtimeOauthAccount: unknown
|
||||
}
|
||||
|
||||
export const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error')
|
||||
Reference in New Issue
Block a user