diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index 3d67f07d20..9ad7f176d3 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -100,7 +100,9 @@ extension SettingsStore { let account = self.selectedClaudeTokenAccount(tokenOverride: tokenOverride) let routing = self.claudeCredentialRouting(account: account) return ProviderSettingsSnapshot.ClaudeProviderSettings( - usageDataSource: self.claudeUsageDataSource, + usageDataSource: self.claudeSnapshotUsageDataSource( + routing: routing, + hasSelectedAccount: account != nil), webExtrasEnabled: self.claudeWebExtrasEnabled, cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing), manualCookieHeader: self.claudeSnapshotCookieHeader( @@ -139,6 +141,23 @@ extension SettingsStore { } } + private func claudeSnapshotUsageDataSource( + routing: ClaudeCredentialRouting, + hasSelectedAccount: Bool) -> ClaudeUsageDataSource + { + guard hasSelectedAccount else { return self.claudeUsageDataSource } + return switch routing { + case .oauth: + .oauth + case .adminAPIKey: + .api + case .webCookie: + .web + case .none: + .auto + } + } + private func claudeSnapshotCookieSource( tokenOverride: TokenAccountOverride?, routing: ClaudeCredentialRouting) -> ProviderCookieSource diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index c4881e95d6..2be94b6f5c 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -36,7 +36,8 @@ extension SettingsStore { nonisolated static func hasAnyTokenCostUsageSources( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, - homeDirectory: URL? = nil) -> Bool + homeDirectory: URL? = nil, + workingDirectory: URL? = nil) -> Bool { let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser @@ -79,23 +80,29 @@ extension SettingsStore { } let claudeRoots: [URL] = { - if let env = env["CLAUDE_CONFIG_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !env.isEmpty + if let configuredRoot = env[ClaudeConfigPaths.configDirectoryEnvironmentKey], + !configuredRoot.isEmpty { - return env.split(separator: ",").map { part in - let raw = String(part).trimmingCharacters(in: .whitespacesAndNewlines) - let url = URL(fileURLWithPath: raw) - if url.lastPathComponent == "projects" { - return url - } - return url.appendingPathComponent("projects", isDirectory: true) - } + return [ClaudeConfigPaths.configRoot( + environment: env, + workingDirectory: workingDirectory) + .appendingPathComponent("projects", isDirectory: true)] } + var pathEnvironment = env + if pathEnvironment["HOME"]?.isEmpty ?? true { + pathEnvironment["HOME"] = home.path + } + let ownerHome = ClaudeConfigPaths.homeDirectory( + environment: pathEnvironment, + workingDirectory: workingDirectory) + let configRoot = ClaudeConfigPaths.configRoot( + environment: pathEnvironment, + workingDirectory: workingDirectory) return [ - home.appendingPathComponent(".config/claude/projects", isDirectory: true), - home.appendingPathComponent(".claude/projects", isDirectory: true), - ] + ClaudeDesktopProjectsLocator.roots(homeDirectory: home, fileManager: fileManager) + ownerHome.appendingPathComponent(".config/claude/projects", isDirectory: true), + configRoot.appendingPathComponent("projects", isDirectory: true), + ] + ClaudeDesktopProjectsLocator.roots(homeDirectory: ownerHome, fileManager: fileManager) }() return claudeRoots.contains(where: hasAnyJsonl(in:)) diff --git a/Sources/CodexBar/UsageStore+ClaudeActiveAccountIdentity.swift b/Sources/CodexBar/UsageStore+ClaudeActiveAccountIdentity.swift new file mode 100644 index 0000000000..4ebff6e328 --- /dev/null +++ b/Sources/CodexBar/UsageStore+ClaudeActiveAccountIdentity.swift @@ -0,0 +1,277 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + nonisolated static let claudeActiveAccountIdentityDefaultsKey = "ClaudeActiveAccountIdentityHashV2" + private nonisolated static let claudeActiveAccountIdentityProfileKeySeparator = ".profile." + + struct ClaudeActiveAccountIdentityReconciliation { + static let unchanged = Self( + changedFromPersistedIdentity: false, + changedDuringFetch: false, + newestIdentity: nil) + + let changedFromPersistedIdentity: Bool + let changedDuringFetch: Bool + let newestIdentity: String? + + var changed: Bool { + self.changedFromPersistedIdentity || self.changedDuringFetch + } + } + + /// The currently-active Claude account UUID, read prompt-free from Claude's owner-selected account config. + /// Claude Code prefers `/.config.json`, then its `.claude.json` fallback, and rewrites + /// `oauthAccount.accountUuid` when the active account changes. Returns nil on absence/corruption. + nonisolated static func activeClaudeAccountUuid(environment: [String: String]) -> String? { + ClaudeActiveAccountProbe.activeClaudeAccountUuid(environment: environment) + } + + nonisolated static func activeClaudeAccountIdentity(environment: [String: String]) -> String? { + self.activeClaudeAccountUuid(environment: environment).map { + self.claudeAccountIdentity($0, environment: environment) + } + } + + nonisolated static func quarantineClaudeCredentialsFileForOAuth( + environment: [String: String]) async + { + await withTaskGroup(of: Void.self) { group in + group.addTask { + _ = ClaudeOAuthCredentialsStore.quarantineCurrentCredentialsFileForOAuth( + environment: environment) + } + await group.waitForAll() + } + } + + nonisolated static func isClaudeCredentialsFileQuarantinedForOAuth( + environment: [String: String]) async -> Bool + { + await withTaskGroup(of: Bool.self, returning: Bool.self) { group in + group.addTask { + ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth( + environment: environment) + } + return await group.next() ?? false + } + } + + /// Compares only hashed identities derived from Claude's plain-text account metadata. A missing identity is + /// treated as an unavailable observation, not as an account, so transient file absence cannot retire good data. + /// The caller commits the newest nonnil observation only after the fetch result is admitted. + func reconcileClaudeActiveAccountIdentity( + beforeFetch: String?, + afterFetch: String?, + observedAccountUuids: [String], + shouldTrack: Bool, + environment: [String: String]) -> ClaudeActiveAccountIdentityReconciliation + { + guard shouldTrack else { return .unchanged } + let observedIdentities = [beforeFetch, afterFetch].compactMap(\.self) + guard !observedIdentities.isEmpty else { return .unchanged } + let defaults = self.settings.userDefaults + let persistedIdentity = Self.persistedClaudeActiveAccountIdentity( + defaults: defaults, + environment: environment, + observedAccountUuids: observedAccountUuids) + + let changedFromPersistedIdentity = persistedIdentity.map { persisted in + observedIdentities.contains { $0 != persisted } + } ?? false + let changedDuringFetch = beforeFetch != nil && afterFetch != nil && beforeFetch != afterFetch + + return ClaudeActiveAccountIdentityReconciliation( + changedFromPersistedIdentity: changedFromPersistedIdentity, + changedDuringFetch: changedDuringFetch, + newestIdentity: afterFetch ?? beforeFetch) + } + + func persistClaudeActiveAccountIdentity( + _ identity: String?, + environment: [String: String]) + { + guard let identity else { return } + let defaults = self.settings.userDefaults + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + defaults.set( + identity, + forKey: Self.claudeActiveAccountIdentityDefaultsKey(profileIdentifier: profileIdentifier)) + + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + if profileIdentifier == defaultProfileIdentifier { + defaults.removeObject(forKey: Self.claudeActiveAccountIdentityDefaultsKey) + } + } + + nonisolated static func persistedClaudeActiveAccountIdentity( + defaults: UserDefaults, + environment: [String: String], + observedAccountUuids: [String]) -> String? + { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + let scopedKey = self.claudeActiveAccountIdentityDefaultsKey(profileIdentifier: profileIdentifier) + if let identity = defaults.string(forKey: scopedKey) { + return self.migrateLegacyClaudeAccountIdentity( + identity, + observedAccountUuids: observedAccountUuids, + scopedKey: scopedKey, + defaults: defaults, + environment: environment) + } + + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + guard profileIdentifier == defaultProfileIdentifier, + let legacyIdentity = defaults.string(forKey: self.claudeActiveAccountIdentityDefaultsKey) + else { + return nil + } + let migratedIdentity = self.migrateLegacyClaudeAccountIdentity( + legacyIdentity, + observedAccountUuids: observedAccountUuids, + scopedKey: scopedKey, + defaults: defaults, + environment: environment) + defaults.set(migratedIdentity, forKey: scopedKey) + defaults.removeObject(forKey: self.claudeActiveAccountIdentityDefaultsKey) + return migratedIdentity + } + + private nonisolated static func claudeActiveAccountIdentityDefaultsKey( + profileIdentifier: String) -> String + { + self.claudeActiveAccountIdentityDefaultsKey + + self.claudeActiveAccountIdentityProfileKeySeparator + + profileIdentifier + } + + nonisolated static func claudeAccountIdentity( + _ uuid: String, + environment: [String: String]) -> String + { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + return self.sha256Hex( + "claude:active-account:v3:\(profileIdentifier):" + + uuid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + private nonisolated static func migrateLegacyClaudeAccountIdentity( + _ identity: String, + observedAccountUuids: [String], + scopedKey: String, + defaults: UserDefaults, + environment: [String: String]) -> String + { + for uuid in Set(observedAccountUuids) { + guard self.legacyClaudeAccountIdentities(uuid, environment: environment).contains(identity) else { + continue + } + let migratedIdentity = self.claudeAccountIdentity(uuid, environment: environment) + defaults.set(migratedIdentity, forKey: scopedKey) + return migratedIdentity + } + return identity + } + + private nonisolated static func legacyClaudeAccountIdentities( + _ uuid: String, + environment: [String: String]) -> Set + { + let root = ClaudeConfigPaths.configRoot(environment: environment) + let fallbackURL = if environment[ClaudeConfigPaths.configDirectoryEnvironmentKey]?.isEmpty == false { + root.appendingPathComponent(".claude.json") + } else { + ClaudeConfigPaths.homeDirectory(environment: environment).appendingPathComponent(".claude.json") + } + return Set([ + root.appendingPathComponent(".config.json"), + fallbackURL, + ].map { url in + self.legacyClaudeAccountIdentity(uuid, accountConfigURL: url) + }) + } + + private nonisolated static func legacyClaudeAccountIdentity( + _ uuid: String, + accountConfigURL: URL) -> String + { + self.sha256Hex( + "claude:active-account:v2:\(accountConfigURL.path):" + + uuid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + #if DEBUG + nonisolated static func _claudeActiveAccountIdentityDefaultsKeyForTesting( + environment: [String: String] = [:]) -> String + { + self.claudeActiveAccountIdentityDefaultsKey( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment)) + } + + static func withActiveClaudeAccountUuidForTesting( + _ uuid: String?, + _ body: () async throws -> T) async rethrows -> T + { + try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue(.value(uuid)) { + try await body() + } + } + + static func withActiveClaudeAccountUuidResolverForTesting( + _ resolver: @escaping @Sendable () -> String?, + _ body: () async throws -> T) async rethrows -> T + { + try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue(.resolver(resolver)) { + try await body() + } + } + + nonisolated static func _activeClaudeAccountIdentityForTesting( + _ uuid: String, + environment: [String: String] = [:]) -> String + { + self.claudeAccountIdentity(uuid, environment: environment) + } + + nonisolated static func _legacyClaudeActiveAccountIdentityForTesting( + _ uuid: String, + accountConfigURL: URL) -> String + { + self.legacyClaudeAccountIdentity(uuid, accountConfigURL: accountConfigURL) + } + + nonisolated static func _activeClaudeAccountIdentityFromEnvironmentForTesting( + _ environment: [String: String]) -> String? + { + self.activeClaudeAccountIdentity(environment: environment) + } + #endif +} + +/// Prompt-free reader for the active Claude account UUID recorded in Claude's owner-selected account config. The +/// `@TaskLocal` test seam lives here (not on `UsageStore`) because Swift forbids stored properties in extensions and +/// task-local storage must be nonisolated, whereas `UsageStore` is `@MainActor`. +private enum ClaudeActiveAccountProbe { + #if DEBUG + enum Override: Sendable { + case value(String?) + case resolver(@Sendable () -> String?) + } + + @TaskLocal static var activeClaudeAccountUuidOverrideForTesting: Override? + #endif + + static func activeClaudeAccountUuid(environment: [String: String]) -> String? { + #if DEBUG + if case let .value(uuid) = self.activeClaudeAccountUuidOverrideForTesting { + return uuid + } + if case let .resolver(resolver) = self.activeClaudeAccountUuidOverrideForTesting { + return resolver() + } + #endif + return ClaudeAccountProfile.accountUuid(environment: environment) + } +} diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index a5d84af753..c9634bdfe2 100644 --- a/Sources/CodexBar/UsageStore+CodexResetCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -120,6 +120,7 @@ extension ProviderFetchOutcome { diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, + claudeOAuthCredentialOwner: result.claudeOAuthCredentialOwner, claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable)), diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 75d26ce540..bd50a239de 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -4,7 +4,9 @@ import Foundation extension UsageStore { nonisolated static let sessionLimitResetDetectorDefaultsKey = "sessionLimitResetDetectorStates" private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates" - private nonisolated static let claudeOAuthAccountUuidMapDefaultsKey = "ClaudeOAuthHistoryOwnerAccountUuidMapV1" + private nonisolated static let claudeOAuthAccountUuidMapDefaultsKey = "ClaudeOAuthHistoryOwnerAccountUuidMapV2" + private nonisolated static let claudeOAuthAccountUuidMapLegacyDefaultsKey = + "ClaudeOAuthHistoryOwnerAccountUuidMapV1" private nonisolated static let claudeOAuthAccountCandidateMapDefaultsKey = "ClaudeOAuthHistoryOwnerAccountCandidateMapV1" nonisolated static let sessionWindowMinutes = 5 * 60 @@ -924,18 +926,14 @@ extension UsageStore { } } - // MARK: - Active Claude account corroboration (~/.claude.json) - - /// The currently-active Claude account UUID, read prompt-free from `~/.claude.json`. This is the only - /// always-fresh, never-gated signal of the active account on a background poll: Claude Code's `/login` - /// updates the Keychain item in place and leaves `~/.claude/.credentials.json` stale, but immediately - /// rewrites `oauthAccount.accountUuid` in this sibling plain file. Returns nil on absence/corruption. - nonisolated static func activeClaudeAccountUuid() -> String? { - ClaudeActiveAccountProbe.activeClaudeAccountUuid() - } - /// Persisted `historyOwnerIdentifier -> hashed active account identity` bindings. nonisolated static func loadClaudeOAuthAccountUuidMap(from userDefaults: UserDefaults) -> [String: String] { + // V1 values hash only the account UUID. V2 adds the owner-selected Claude config path, so a + // V1 binding can never match after upgrade and would quarantine owner-mediated samples forever. + // The history owner key itself is unchanged, so discarding the obsolete binding preserves history. + if userDefaults.object(forKey: self.claudeOAuthAccountUuidMapLegacyDefaultsKey) != nil { + userDefaults.removeObject(forKey: self.claudeOAuthAccountUuidMapLegacyDefaultsKey) + } guard let data = userDefaults.data(forKey: claudeOAuthAccountUuidMapDefaultsKey) else { return [:] } do { return try JSONDecoder().decode([String: String].self, from: data) @@ -1080,30 +1078,6 @@ extension UsageStore { } } - nonisolated static func activeClaudeAccountIdentity() -> String? { - self.activeClaudeAccountUuid().map(self.claudeAccountIdentity) - } - - private nonisolated static func claudeAccountIdentity(_ uuid: String) -> String { - self.sha256Hex( - "claude:active-account:v1:\(uuid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())") - } - - #if DEBUG - static func withActiveClaudeAccountUuidForTesting( - _ uuid: String?, - _ body: () async throws -> T) async rethrows -> T - { - try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue( - .value(uuid), - operation: body) - } - - nonisolated static func _activeClaudeAccountIdentityForTesting(_ uuid: String) -> String { - self.claudeAccountIdentity(uuid) - } - #endif - private func resolvePlanUtilizationAccountKey( provider: UsageProvider, snapshot: UsageSnapshot?, @@ -1638,46 +1612,3 @@ actor PlanUtilizationHistoryPersistenceCoordinator { }.value } } - -/// Prompt-free reader for the active Claude account UUID recorded in `~/.claude.json`. The `@TaskLocal` test -/// seam lives here (not on `UsageStore`) because Swift forbids stored properties in extensions and task-local -/// storage must be nonisolated, whereas `UsageStore` is `@MainActor`. -private enum ClaudeActiveAccountProbe { - #if DEBUG - enum Override: Sendable { - case value(String?) - } - - @TaskLocal static var activeClaudeAccountUuidOverrideForTesting: Override? - #endif - - private struct ClaudeConfigAccount: Decodable { - struct OAuthAccount: Decodable { - let accountUuid: String? - } - - let oauthAccount: OAuthAccount? - } - - static func activeClaudeAccountUuid() -> String? { - #if DEBUG - if case let .value(uuid) = self.activeClaudeAccountUuidOverrideForTesting { - return uuid - } - #endif - // `~/.claude.json` is a SIBLING of `.claude/`, not inside it. Home resolution mirrors - // `ClaudeOAuthCredentials.defaultCredentialsURL()`. This intentionally does NOT honor - // CLAUDE_CONFIG_DIR: the credential store that yields `historyOwnerIdentifier` is purely - // home-relative, so the accountUuid corroboration must resolve against the same home or the - // two signals would point at different accounts. - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json") - guard let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data), - let uuid = decoded.oauthAccount?.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), - !uuid.isEmpty - else { - return nil - } - return uuid - } -} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index a133454949..0343a4c8e8 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -42,6 +42,36 @@ extension UsageStore { let missingWindowBackfillSnapshot: UsageSnapshot? } + private struct ClaudeRefreshReconciliation { + let disposition: ClaudeRefreshDisposition + let oauthHistoryPersistentRefHash: String? + let oauthActiveAccountObservation: ClaudeOAuthActiveAccountObservation + } + + private enum ClaudeRefreshDisposition { + case apply + case retry + case retryOwnerCLI + case discard + } + + private enum ProviderRefreshRetryMode { + case ordinary + case claudeOwnerCLIRecovery + } + + private struct ClaudeRefreshReconciliationInput { + let provider: UsageProvider + let outcome: ProviderFetchOutcome + let environment: [String: String] + let dataSource: ClaudeUsageDataSource? + let priorSourceLabel: String? + let beforeFetch: ClaudeRefreshAuthState? + let activeAccountIdentitySourceEligible: Bool + let ownerCLIRecoveryPass: Bool + let generation: UInt64 + } + private static func warningAccountDiscriminator( provider: UsageProvider, tokenAccount: ProviderTokenAccount?, @@ -223,10 +253,19 @@ extension UsageStore { self.refreshingProviders.remove(provider) } } - await self.refreshProviderNow( - provider, - allowDisabled: allowDisabled, - generation: generation) + var retryMode: ProviderRefreshRetryMode? + while !Task.isCancelled, + self.isCurrentProviderRefreshGeneration(provider, generation: generation) + { + retryMode = await self.refreshProviderPass( + provider, + allowDisabled: allowDisabled, + generation: generation, + retryMode: retryMode) + if retryMode == nil { + break + } + } } private func prepareCodexRefreshPublication() -> CodexRefreshPublicationPreparation { @@ -287,32 +326,35 @@ extension UsageStore { missingWindowBackfillSnapshot: missingWindowBackfillSnapshot) } - private func refreshProviderNow( + /// Runs one provider fetch pass. A nonnil result keeps the retry inside the current coordinator request, so + /// callers (including `runRefresh`) remain suspended until the account-stable replacement pass completes. + private func refreshProviderPass( _ provider: UsageProvider, allowDisabled: Bool, - generation: UInt64) async + generation: UInt64, + retryMode: ProviderRefreshRetryMode?) async -> ProviderRefreshRetryMode? { - guard let spec = await self.providerRefreshSpec(provider) else { return } - guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + guard let spec = await self.providerRefreshSpec(provider) else { return nil } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil } let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil let codexExpectedGuard = codexPreparation?.expectedGuard let codexLimitResetOwnerKey = codexPreparation?.limitResetOwnerKey if !spec.isEnabled(), !allowDisabled { await self.clearDisabledProviderRefreshState(provider) - return + return nil } if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { await self.refreshCodexVisibleAccountsForMenu(generation: generation) - return + return nil } else if provider == .codex { self.codexAccountSnapshots = [] } if provider == .kilo, self.shouldFanOutKiloScopes() { await self.refreshKiloScopes(generation: generation) - guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil } // Continue to also fetch the personal snapshot through the regular path // so the existing single-card render keeps working when only personal is shown. // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. @@ -330,7 +372,7 @@ extension UsageStore { provider: provider, accounts: tokenAccounts, generation: generation) - return + return nil } else { _ = await MainActor.run { self.reconcileSelectedTokenAccountSnapshotBeforeRefresh( @@ -339,13 +381,25 @@ extension UsageStore { } } + let tokenAccount = self.settings.effectiveSelectedTokenAccount(for: provider) + let fetchContext = self.makeFetchContext( + provider: provider, + override: nil, + claudeOwnerCLIRecoveryOnly: retryMode == .claudeOwnerCLIRecovery) + let claudeHasAdminAPIKey = ClaudeAdminAPISettingsReader.apiKey(environment: fetchContext.env) != nil + let claudeActiveAccountIdentitySourceEligible = Self.shouldTrackClaudeActiveAccountIdentity( + provider: provider, + dataSource: fetchContext.settings?.claude?.usageDataSource, + hasSelectedTokenAccount: tokenAccount != nil, + hasAdminAPIKey: claudeHasAdminAPIKey) + let priorClaudeSourceLabel = provider == .claude ? self.lastSourceLabels[.claude] : nil self.diagnostics[provider] = nil - let claudeAuthStateBeforeFetch = provider == .claude - ? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true) + let claudeAuthStateBeforeFetch = claudeActiveAccountIdentitySourceEligible + ? await Self.captureClaudeRefreshAuthState( + invalidateCredentialsFile: true, + environment: fetchContext.env) : nil - let tokenAccount = self.settings.effectiveSelectedTokenAccount(for: provider) let priorTokenAccountSnapshot = self.tokenAccountSnapshot(provider: provider, account: tokenAccount) - let fetchContext = self.makeFetchContext(provider: provider, override: nil) let descriptor = spec.descriptor let codexResetCreditsFetcher = self.codexResetCreditsFetcher() let previousCodexSnapshot = codexPreparation?.previousSnapshot @@ -381,7 +435,7 @@ extension UsageStore { self.retireCodexStateIfRefreshOwnerChanged( expectedGuard: codexExpectedGuard, generation: generation) - return + return nil } guard let admittedOutcome = await Self.codexOutcomeAdmittedForPublication( initialOutcome: initialOutcome, @@ -394,7 +448,7 @@ extension UsageStore { expectedGuard: codexExpectedGuard, generation: generation) } - return + return nil } if case let .success(result) = admittedOutcome.result, let codexExpectedGuard, @@ -405,55 +459,169 @@ extension UsageStore { self.retireCodexStateIfRefreshOwnerChanged( expectedGuard: codexExpectedGuard, generation: generation) - return + return nil } outcome = admittedOutcome } else { outcome = initialOutcome } - let claudeHistoryAccountState = provider == .claude - ? await Self.captureClaudeHistoryAccountState() - : nil - let claudeAuthFingerprintAfterFetch = claudeHistoryAccountState?.fingerprintToken - let claudeAuthChangedDuringFetch = Self.claudeAuthChangedDuringFetch( + let claudeReconciliation = await self.reconcileClaudeRefreshAfterFetch(input: .init( provider: provider, + outcome: outcome, + environment: fetchContext.env, + dataSource: fetchContext.settings?.claude?.usageDataSource, + priorSourceLabel: priorClaudeSourceLabel, beforeFetch: claudeAuthStateBeforeFetch, - afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch) - await Self.invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: claudeAuthChangedDuringFetch) - let claudeCredentialsChanged = Self.claudeCredentialsChanged( - beforeFetch: claudeAuthStateBeforeFetch, - changedDuringFetch: claudeAuthChangedDuringFetch) - let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange( - beforeFetch: claudeAuthStateBeforeFetch, - changedDuringFetch: claudeAuthChangedDuringFetch) - let claudeOAuthHistoryPersistentRefHash = Self.stableClaudeKeychainPersistentRefHash( - beforeFetch: claudeAuthStateBeforeFetch, - afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch, - afterFetchPersistentRefHash: claudeHistoryAccountState?.keychainPersistentRefHash, - accountStateWasStable: claudeHistoryAccountState?.wasStable == true) - let claudeOAuthActiveAccountObservation = Self.claudeOAuthActiveAccountObservation( - beforeFetch: claudeAuthStateBeforeFetch, - afterFetch: claudeHistoryAccountState) - // Credential detection consumes change markers. Clean up before rejecting a superseded generation; - // replacement refreshes wait for their predecessor, so they cannot race this state reset. - if claudeCredentialsChanged { - await self.clearClaudeCredentialDerivedStateForCredentialSwap() - } - if shouldConsumeClaudeKeychainFingerprint { - _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + activeAccountIdentitySourceEligible: claudeActiveAccountIdentitySourceEligible, + ownerCLIRecoveryPass: retryMode == .claudeOwnerCLIRecovery, + generation: generation)) + let outcomeContext = ProviderRefreshOutcomeContext( + generation: generation, + codexExpectedGuard: codexExpectedGuard, + tokenAccount: tokenAccount, + priorTokenAccountSnapshot: priorTokenAccountSnapshot, + codexLimitResetOwnerKey: codexLimitResetOwnerKey, + claudeOAuthHistoryPersistentRefHash: claudeReconciliation.oauthHistoryPersistentRefHash, + claudeOAuthActiveAccountObservation: claudeReconciliation.oauthActiveAccountObservation) + return await self.completeProviderRefreshPass( + provider: provider, + outcome: outcome, + reconciliation: claudeReconciliation, + context: outcomeContext) + } + + private func completeProviderRefreshPass( + provider: UsageProvider, + outcome: ProviderFetchOutcome, + reconciliation: ClaudeRefreshReconciliation, + context: ProviderRefreshOutcomeContext) async -> ProviderRefreshRetryMode? + { + switch reconciliation.disposition { + case .retry: + return .ordinary + case .retryOwnerCLI: + return .claudeOwnerCLIRecovery + case .discard: + return nil + case .apply: + break } - guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return nil } await self.applyProviderRefreshOutcome( provider: provider, outcome: outcome, - context: ProviderRefreshOutcomeContext( - generation: generation, - codexExpectedGuard: codexExpectedGuard, - tokenAccount: tokenAccount, - priorTokenAccountSnapshot: priorTokenAccountSnapshot, - codexLimitResetOwnerKey: codexLimitResetOwnerKey, - claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash, - claudeOAuthActiveAccountObservation: claudeOAuthActiveAccountObservation)) + context: context) + return nil + } + + private func reconcileClaudeRefreshAfterFetch( + input: ClaudeRefreshReconciliationInput) async -> ClaudeRefreshReconciliation + { + guard input.provider == .claude else { + return ClaudeRefreshReconciliation( + disposition: .apply, + oauthHistoryPersistentRefHash: nil, + oauthActiveAccountObservation: .changed) + } + let historyAccountState = await Self.captureClaudeHistoryAccountState(environment: input.environment) + guard self.isCurrentProviderRefreshGeneration(input.provider, generation: input.generation) else { + return ClaudeRefreshReconciliation( + disposition: .discard, + oauthHistoryPersistentRefHash: nil, + oauthActiveAccountObservation: .changed) + } + let fingerprintAfterFetch = historyAccountState.fingerprintToken + let authChangedDuringFetch = Self.claudeAuthChangedDuringFetch( + provider: input.provider, + beforeFetch: input.beforeFetch, + afterFetchFingerprintToken: fingerprintAfterFetch) + let shouldTrackActiveAccount = Self.shouldReconcileClaudeActiveAccountIdentity( + sourceEligible: input.activeAccountIdentitySourceEligible, + dataSource: input.dataSource, + outcome: input.outcome, + priorSourceLabel: input.priorSourceLabel) + await Self.invalidateClaudeCredentialsFileCacheIfNeeded( + changedDuringFetch: shouldTrackActiveAccount && authChangedDuringFetch, + environment: input.environment) + guard self.isCurrentProviderRefreshGeneration(input.provider, generation: input.generation) else { + return ClaudeRefreshReconciliation( + disposition: .discard, + oauthHistoryPersistentRefHash: nil, + oauthActiveAccountObservation: .changed) + } + let activeAccountChangedDuringFetch = Self.claudeActiveAccountChangedDuringFetch( + beforeFetch: input.beforeFetch?.activeAccountIdentity, + afterFetch: historyAccountState.activeAccountIdentity, + shouldTrack: shouldTrackActiveAccount, + successfulCLIOutcome: Self.isSuccessfulClaudeCLIOutcome(input.outcome)) + let activeAccountReconciliation = self.reconcileClaudeActiveAccountIdentity( + beforeFetch: input.beforeFetch?.activeAccountIdentity, + afterFetch: historyAccountState.activeAccountIdentity, + observedAccountUuids: [input.beforeFetch?.activeAccountUuid, historyAccountState.activeAccountUuid] + .compactMap(\.self), + shouldTrack: shouldTrackActiveAccount, + environment: input.environment) + let credentialsChanged = shouldTrackActiveAccount && ( + Self.claudeCredentialsChanged( + beforeFetch: input.beforeFetch, + changedDuringFetch: authChangedDuringFetch) || activeAccountReconciliation.changed) + let successfulOAuth = Self.isSuccessfulClaudeOAuthOutcome(input.outcome) + let successfulOAuthCredentialOwner = Self.successfulClaudeOAuthCredentialOwner(input.outcome) + let activeAccountMismatch = successfulOAuth && ( + activeAccountChangedDuringFetch || activeAccountReconciliation.changedFromPersistedIdentity) + let quarantinedCredentialsFile = if successfulOAuthCredentialOwner == .claudeCLI { + await Self.isClaudeCredentialsFileQuarantinedForOAuth(environment: input.environment) + } else { + false + } + let oauthAccountMismatch = activeAccountMismatch || quarantinedCredentialsFile + if oauthAccountMismatch { + if activeAccountMismatch, successfulOAuthCredentialOwner == .claudeCLI { + await Self.quarantineClaudeCredentialsFileForOAuth(environment: input.environment) + } + await Self.invalidateClaudeOAuthCache(environment: input.environment) + guard self.isCurrentProviderRefreshGeneration(input.provider, generation: input.generation) else { + return ClaudeRefreshReconciliation( + disposition: .discard, + oauthHistoryPersistentRefHash: nil, + oauthActiveAccountObservation: .changed) + } + } + let ownerCLIRecoverySucceeded = !input.ownerCLIRecoveryPass || Self.isSuccessfulClaudeCLIOutcome(input.outcome) + if !oauthAccountMismatch, !activeAccountChangedDuringFetch, ownerCLIRecoverySucceeded { + self.persistClaudeActiveAccountIdentity( + activeAccountReconciliation.newestIdentity, + environment: input.environment) + } + let sourceAuthorityChanged = Self.claudeSourceAuthorityChanged( + priorSourceLabel: input.priorSourceLabel, + dataSource: input.dataSource, + outcome: input.outcome) + let persistentRefHash = Self.stableClaudeKeychainPersistentRefHash( + beforeFetch: input.beforeFetch, + afterFetchFingerprintToken: fingerprintAfterFetch, + afterFetchPersistentRefHash: historyAccountState.keychainPersistentRefHash, + accountStateWasStable: historyAccountState.wasStable) + let activeAccountObservation = Self.claudeOAuthActiveAccountObservation( + beforeFetch: input.beforeFetch, + afterFetch: historyAccountState) + + // Only the ambient CLI authority observes Claude's account/config files. Source-authority changes apply to + // every Claude route, but retire only the live projection; configured token-account caches remain isolated. + if credentialsChanged || activeAccountChangedDuringFetch || sourceAuthorityChanged { + self.clearClaudeCredentialDerivedStateForCredentialSwap() + } + let disposition: ClaudeRefreshDisposition = if oauthAccountMismatch { + .retryOwnerCLI + } else if activeAccountChangedDuringFetch { + .retry + } else { + .apply + } + return ClaudeRefreshReconciliation( + disposition: disposition, + oauthHistoryPersistentRefHash: persistentRefHash, + oauthActiveAccountObservation: activeAccountObservation) } private func applyProviderRefreshOutcome( @@ -778,6 +946,7 @@ extension UsageStore { let credentialsFileChanged: Bool let keychainFingerprintChanged: Bool let keychainPersistentRefHash: String? + let activeAccountUuid: String? let activeAccountIdentity: String? let accountStateWasStable: Bool } @@ -785,6 +954,7 @@ extension UsageStore { private struct ClaudeHistoryAccountState { let fingerprintToken: String let keychainPersistentRefHash: String? + let activeAccountUuid: String? let activeAccountIdentity: String? let wasStable: Bool } @@ -798,11 +968,150 @@ extension UsageStore { changedDuringFetch } - private nonisolated static func shouldConsumeClaudeKeychainFingerprintChange( - beforeFetch: ClaudeRefreshAuthState?, - changedDuringFetch: Bool) -> Bool + nonisolated static func shouldTrackClaudeActiveAccountIdentity( + provider: UsageProvider, + dataSource: ClaudeUsageDataSource?, + hasSelectedTokenAccount: Bool, + hasAdminAPIKey: Bool) -> Bool + { + guard provider == .claude, !hasSelectedTokenAccount else { return false } + switch dataSource { + case .cli: + return true + case .auto: + return !hasAdminAPIKey + case .oauth: + // Explicit OAuth records sourced from the Claude file/cache require a stable account + // observation before their unavailable-Keychain owner can enter history. + return true + case .api, .web, nil: + return false + } + } + + private nonisolated static func shouldReconcileClaudeActiveAccountIdentity( + sourceEligible: Bool, + dataSource: ClaudeUsageDataSource?, + outcome: ProviderFetchOutcome, + priorSourceLabel: String?) -> Bool + { + guard sourceEligible else { return false } + switch outcome.result { + case let .success(result): + // An OAuth result uses the owner-selected Claude profile just as the CLI route does. + // A successful OAuth result must therefore reconcile a profile/account swap before + // its reset snapshots can be published or backfilled. + return result.strategyKind == .cli || result.strategyKind == .oauth + case .failure: + if dataSource == .cli { + return true + } + let normalizedPriorSource = priorSourceLabel?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedPriorSource == "claude" || normalizedPriorSource == "cli" + } + } + + private nonisolated static func claudeActiveAccountChangedDuringFetch( + beforeFetch: String?, + afterFetch: String?, + shouldTrack: Bool, + successfulCLIOutcome: Bool) -> Bool + { + guard shouldTrack, let beforeFetch, beforeFetch != afterFetch else { return false } + return afterFetch != nil || successfulCLIOutcome + } + + private nonisolated static func isSuccessfulClaudeCLIOutcome(_ outcome: ProviderFetchOutcome) -> Bool { + guard case let .success(result) = outcome.result else { return false } + if case .cli = result.strategyKind { + return true + } + return false + } + + private nonisolated static func isSuccessfulClaudeOAuthOutcome(_ outcome: ProviderFetchOutcome) -> Bool { + guard case let .success(result) = outcome.result else { return false } + if case .oauth = result.strategyKind { + return true + } + return false + } + + private nonisolated static func successfulClaudeOAuthCredentialOwner( + _ outcome: ProviderFetchOutcome) -> ClaudeOAuthCredentialOwner? { - beforeFetch?.keychainFingerprintChanged == true || changedDuringFetch + guard case let .success(result) = outcome.result, + result.strategyKind == .oauth + else { return nil } + return result.claudeOAuthCredentialOwner + } + + private enum ClaudeSourceAuthority: Equatable { + case cli + case web + case api + case oauth + + init?(sourceLabel: String?) { + switch sourceLabel?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude", "cli": + self = .cli + case "web": + self = .web + case "admin-api", "api": + self = .api + case "oauth": + self = .oauth + default: + return nil + } + } + + init?(result: ProviderFetchResult) { + switch result.strategyKind { + case .cli: + self = .cli + case .web, .webDashboard: + self = .web + case .apiToken: + self = .api + case .oauth: + self = .oauth + case .localProbe: + self.init(sourceLabel: result.sourceLabel) + } + } + + init?(dataSource: ClaudeUsageDataSource?) { + switch dataSource { + case .cli: + self = .cli + case .web: + self = .web + case .api: + self = .api + case .oauth: + self = .oauth + case .auto, nil: + return nil + } + } + } + + private nonisolated static func claudeSourceAuthorityChanged( + priorSourceLabel: String?, + dataSource: ClaudeUsageDataSource?, + outcome: ProviderFetchOutcome) -> Bool + { + guard let priorAuthority = ClaudeSourceAuthority(sourceLabel: priorSourceLabel) else { return false } + let currentAuthority: ClaudeSourceAuthority? = switch outcome.result { + case let .success(result): + ClaudeSourceAuthority(result: result) + case .failure: + ClaudeSourceAuthority(dataSource: dataSource) + } + guard let currentAuthority else { return false } + return priorAuthority != currentAuthority } private nonisolated static func claudeAuthChangedDuringFetch( @@ -814,29 +1123,29 @@ extension UsageStore { } private nonisolated static func captureClaudeRefreshAuthState( - invalidateCredentialsFile: Bool) async -> ClaudeRefreshAuthState + invalidateCredentialsFile: Bool, + environment: [String: String]) async -> ClaudeRefreshAuthState { await withTaskGroup(of: ClaudeRefreshAuthState.self, returning: ClaudeRefreshAuthState.self) { group in group.addTask { let credentialsFileChanged = invalidateCredentialsFile - ? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + ? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: environment) : false - let keychainFingerprintChanged = ClaudeOAuthCredentialsStore - .claudeKeychainFingerprintChangedWithoutConsuming() - let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() - let persistentRefBefore = ClaudeOAuthCredentialsStore - .claudeKeychainPersistentRefHashWithoutPrompt() - let activeAccountIdentity = Self.activeClaudeAccountIdentity() - let persistentRefAfter = ClaudeOAuthCredentialsStore - .claudeKeychainPersistentRefHashWithoutPrompt() - let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() + let fingerprintBefore = ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate(environment: environment) ?? "none" + let activeAccountUuid = Self.activeClaudeAccountUuid(environment: environment) + let activeAccountIdentity = activeAccountUuid.map { + Self.claudeAccountIdentity($0, environment: environment) + } + let fingerprintAfter = ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate(environment: environment) ?? "none" let accountStateWasStable = fingerprintBefore == fingerprintAfter - && persistentRefBefore == persistentRefAfter return ClaudeRefreshAuthState( fingerprintToken: fingerprintAfter, credentialsFileChanged: credentialsFileChanged, - keychainFingerprintChanged: keychainFingerprintChanged, - keychainPersistentRefHash: persistentRefAfter, + keychainFingerprintChanged: false, + keychainPersistentRefHash: nil, + activeAccountUuid: activeAccountUuid, activeAccountIdentity: activeAccountIdentity, accountStateWasStable: accountStateWasStable) } @@ -844,20 +1153,24 @@ extension UsageStore { } } - private nonisolated static func captureClaudeHistoryAccountState() async -> ClaudeHistoryAccountState { + private nonisolated static func captureClaudeHistoryAccountState( + environment: [String: String]) async -> ClaudeHistoryAccountState + { await withTaskGroup(of: ClaudeHistoryAccountState.self, returning: ClaudeHistoryAccountState.self) { group in group.addTask { - let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() - let persistentRefBefore = ClaudeOAuthCredentialsStore - .claudeKeychainPersistentRefHashWithoutPrompt() - let activeAccountIdentity = Self.activeClaudeAccountIdentity() - let persistentRefAfter = ClaudeOAuthCredentialsStore - .claudeKeychainPersistentRefHashWithoutPrompt() - let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() - let wasStable = fingerprintBefore == fingerprintAfter && persistentRefBefore == persistentRefAfter + let fingerprintBefore = ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate(environment: environment) ?? "none" + let activeAccountUuid = Self.activeClaudeAccountUuid(environment: environment) + let activeAccountIdentity = activeAccountUuid.map { + Self.claudeAccountIdentity($0, environment: environment) + } + let fingerprintAfter = ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate(environment: environment) ?? "none" + let wasStable = fingerprintBefore == fingerprintAfter return ClaudeHistoryAccountState( fingerprintToken: fingerprintAfter, - keychainPersistentRefHash: persistentRefAfter, + keychainPersistentRefHash: nil, + activeAccountUuid: activeAccountUuid, activeAccountIdentity: activeAccountIdentity, wasStable: wasStable) } @@ -911,6 +1224,7 @@ extension UsageStore { credentialsFileChanged: false, keychainFingerprintChanged: false, keychainPersistentRefHash: beforeFetchPersistentRefHash, + activeAccountUuid: nil, activeAccountIdentity: nil, accountStateWasStable: true), afterFetchFingerprintToken: afterFetchFingerprintToken, @@ -930,53 +1244,53 @@ extension UsageStore { credentialsFileChanged: false, keychainFingerprintChanged: false, keychainPersistentRefHash: "before-ref", + activeAccountUuid: nil, activeAccountIdentity: identityBeforeFetch, accountStateWasStable: beforeFetchWasStable), afterFetch: ClaudeHistoryAccountState( fingerprintToken: "after", keychainPersistentRefHash: "after-ref", + activeAccountUuid: nil, activeAccountIdentity: identityAfterFetch, wasStable: afterFetchWasStable)) } #endif - private nonisolated static func invalidateClaudeCredentialsFileCacheIfChanged() async -> Bool { + private nonisolated static func invalidateClaudeCredentialsFileCacheIfChanged( + environment: [String: String]) async -> Bool + { await withTaskGroup(of: Bool.self, returning: Bool.self) { group in group.addTask { - ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: environment) } return await group.next()! } } - private nonisolated static func invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: Bool) async { + private nonisolated static func invalidateClaudeCredentialsFileCacheIfNeeded( + changedDuringFetch: Bool, + environment: [String: String]) async + { guard changedDuringFetch else { return } - _ = await self.invalidateClaudeCredentialsFileCacheIfChanged() + _ = await self.invalidateClaudeCredentialsFileCacheIfChanged(environment: environment) } - private nonisolated static func consumeClaudeKeychainFingerprintChangeWithoutPrompt() async -> Bool { - await withTaskGroup(of: Bool.self, returning: Bool.self) { group in + private nonisolated static func invalidateClaudeOAuthCache(environment: [String: String]) async { + await withTaskGroup(of: Void.self) { group in group.addTask { - ClaudeOAuthCredentialsStore.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + ClaudeOAuthCredentialsStore.invalidateCache(environment: environment) } - return await group.next()! - } - } - - private func clearClaudeCredentialDerivedStateForCredentialSwap() async { - await MainActor.run { - self.clearClaudeCredentialDerivedStateForCredentialSwapNow() + await group.waitForAll() } } - private func clearClaudeCredentialDerivedStateForCredentialSwapNow() { + private func clearClaudeCredentialDerivedStateForCredentialSwap() { self.widgetUsagePreservationBlockedProviders.insert(.claude) self.snapshots.removeValue(forKey: .claude) self.lastKnownResetSnapshots.removeValue(forKey: .claude) self.errors[.claude] = nil self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.lastSourceLabels.removeValue(forKey: .claude) - self.accountSnapshots.removeValue(forKey: .claude) self.clearTokenSnapshot(for: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 219251ec02..6a33e928d9 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -912,7 +912,8 @@ extension UsageStore { provider: UsageProvider, override: TokenAccountOverride?, codexActiveSourceOverride: CodexActiveSource? = nil, - includeCredits: Bool = false) -> ProviderFetchContext + includeCredits: Bool = false, + claudeOwnerCLIRecoveryOnly: Bool = false) -> ProviderFetchContext { let account = ProviderTokenAccountSelection.selectedAccount( provider: provider, @@ -991,6 +992,7 @@ extension UsageStore { } }, costUsageHistoryDays: self.settings.costUsageHistoryDays, + claudeOwnerCLIRecoveryOnly: claudeOwnerCLIRecoveryOnly, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( refreshInterval: self.normalRefreshIntervalForHeuristics())) diff --git a/Sources/CodexBarCLI/CLIOptions.swift b/Sources/CodexBarCLI/CLIOptions.swift index b13622efbc..90e7614e2c 100644 --- a/Sources/CodexBarCLI/CLIOptions.swift +++ b/Sources/CodexBarCLI/CLIOptions.swift @@ -63,6 +63,11 @@ struct UsageOptions: CommanderParsable { @Option(name: .long("source"), help: Self.sourceHelp) var source: String? + @Flag( + name: .long("app-auto-verifier"), + help: "Exercise the app's Claude Auto route (verification only; requires --provider claude --source auto)") + var appAutoVerifier: Bool = false + @Option(name: .long("web-timeout"), help: "Web fetch timeout (seconds; source=auto or web)") var webTimeout: Double? diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 140cf40edf..312f71ffb1 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -5,7 +5,7 @@ import Foundation struct UsageCommandContext { let format: OutputFormat let includeCredits: Bool - let sourceModeOverride: ProviderSourceMode? + var sourceModeOverride: ProviderSourceMode? let antigravityPlanDebug: Bool let augmentDebug: Bool let webDebugDumpHTML: Bool @@ -19,6 +19,8 @@ struct UsageCommandContext { let fetcher: UsageFetcher let claudeFetcher: ClaudeUsageFetcher let browserDetection: BrowserDetection + /// A verifier-only route that invokes the same app provider pipeline while retaining CLI JSON output. + var providerRuntime: ProviderRuntime = .cli /// True for long-lived hosts (`codexbar serve`) that keep warm provider /// helper sessions (such as the managed Antigravity `agy` process) alive /// between fetches instead of resetting after each one-shot fetch. @@ -81,8 +83,9 @@ extension CodexBarCLI { output: output, kind: .args) } - let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug") - let augmentDebug = values.flags.contains("augmentDebug") + let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug"), + augmentDebug = values.flags.contains("augmentDebug") + let appAutoVerifier = values.flags.contains("appAutoVerifier") let webDebugDumpHTML = values.flags.contains("webDebugDumpHtml") let webTimeout: TimeInterval do { @@ -90,8 +93,7 @@ extension CodexBarCLI { } catch { Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args) } - let verbose = values.flags.contains("verbose") - let noColor = values.flags.contains("noColor") + let verbose = values.flags.contains("verbose"), noColor = values.flags.contains("noColor") let useColor = Self.shouldUseColor(noColor: noColor, format: format) let resetStyle = Self.resetTimeDisplayStyleFromDefaults() let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults() @@ -112,6 +114,19 @@ extension CodexBarCLI { kind: .args) } + if let message = Self.appAutoVerifierArgumentError( + enabled: appAutoVerifier, + providers: providerList, + sourceMode: parsedSourceMode, + tokenSelection: tokenSelection) + { + Self.exit( + code: .failure, + message: "Error: \(message)", + output: output, + kind: .args) + } + if tokenSelection.usesOverride { guard providerList.count == 1 else { Self.exit( @@ -141,7 +156,8 @@ extension CodexBarCLI { tokenContext = try TokenAccountCLIContext( selection: tokenSelection, config: config, - verbose: verbose) + verbose: verbose, + resolutionScope: appAutoVerifier ? .ambientAccount : .configuredAccounts) } catch { Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config) } @@ -165,10 +181,31 @@ extension CodexBarCLI { includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], fetcher: fetcher, claudeFetcher: claudeFetcher, - browserDetection: browserDetection) + browserDetection: browserDetection, + providerRuntime: appAutoVerifier ? .app : .cli) for p in providerList { let status = includeStatus ? await Self.fetchStatus(for: p) : nil + if appAutoVerifier { + // Background app Auto intentionally launches the opaque Claude owner CLI only after a successful + // user-initiated fetch has established this process's account-scoped availability marker. Recreate + // that real app lifecycle before exercising the background route; discard the foreground payload. + var establishmentCommand = command + establishmentCommand.sourceModeOverride = .cli + let establishment = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await Self.fetchUsageOutputs( + provider: p, + status: status, + tokenContext: tokenContext, + command: establishmentCommand) + } + if establishment.exitCode != .success { + exitCode = establishment.exitCode + sections.append(contentsOf: establishment.sections) + payload.append(contentsOf: establishment.payload) + continue + } + } // CLI usage should not clear Keychain cooldowns or attempt interactive Keychain prompts. let output = await ProviderInteractionContext.$current.withValue(.background) { await Self.fetchUsageOutputs( @@ -196,6 +233,22 @@ extension CodexBarCLI { Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) } + static func appAutoVerifierArgumentError( + enabled: Bool, + providers: [UsageProvider], + sourceMode: ProviderSourceMode?, + tokenSelection: TokenAccountCLISelection) -> String? + { + guard enabled else { return nil } + guard providers == [.claude], sourceMode == .auto else { + return "--app-auto-verifier requires --provider claude --source auto." + } + guard !tokenSelection.usesOverride else { + return "--app-auto-verifier does not accept token-account selection." + } + return nil + } + static func fetchUsageOutputs( provider: UsageProvider, status: ProviderStatusPayload?, @@ -421,7 +474,7 @@ extension CodexBarCLI { #endif let fetchContext = ProviderFetchContext( - runtime: .cli, + runtime: command.providerRuntime, sourceMode: effectiveSourceMode, includeCredits: command.includeCredits, webTimeout: command.webTimeout, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4ee5c33209..358150a333 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -12,6 +12,11 @@ struct TokenAccountCLISelection { } } +enum TokenAccountCLIResolutionScope { + case configuredAccounts + case ambientAccount +} + enum TokenAccountCLIError: LocalizedError { case noAccounts(UsageProvider) case accountNotFound(UsageProvider, String) @@ -40,6 +45,7 @@ struct TokenAccountCLIContext { selection: TokenAccountCLISelection, config: CodexBarConfig, verbose _: Bool, + resolutionScope: TokenAccountCLIResolutionScope = .configuredAccounts, baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, managedCodexAccountStoreURL: URL? = nil) throws { @@ -47,10 +53,15 @@ struct TokenAccountCLIContext { self.config = config self.baseEnvironment = baseEnvironment self.managedCodexAccountStoreURL = managedCodexAccountStoreURL - self.accountsByProvider = Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in - guard let accounts = provider.tokenAccounts else { return nil } - return (provider.id, accounts) - }) + self.accountsByProvider = switch resolutionScope { + case .configuredAccounts: + Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in + guard let accounts = provider.tokenAccounts else { return nil } + return (provider.id, accounts) + }) + case .ambientAccount: + [:] + } } func resolvedAccounts(for provider: UsageProvider) throws -> [ProviderTokenAccount] { @@ -110,7 +121,14 @@ struct TokenAccountCLIContext { codexActiveSourceOverride: codexActiveSourceOverride)) case .claude: let routing = self.claudeCredentialRouting(account: account, config: config) - let claudeSource: ClaudeUsageDataSource = if routing.adminAPIKey != nil { + let claudeSource: ClaudeUsageDataSource = if account != nil { + switch routing { + case .adminAPIKey: .api + case .oauth: .oauth + case .webCookie: .web + case .none: .auto + } + } else if routing.adminAPIKey != nil { .api } else if routing.isOAuth { .oauth @@ -447,20 +465,10 @@ struct TokenAccountCLIContext { let config = self.providerConfig(for: provider) let routing = self.claudeCredentialRouting(account: account, config: config) - if base == .auto { - if routing.adminAPIKey != nil { - return .api - } - return routing.isOAuth ? .oauth : base - } - - guard base == .cli, account != nil else { - return base - } + guard account != nil else { return base } - // Claude CLI usage is ambient to the active local CLI profile, so per-token-account - // CLI reads can be mislabeled as separate accounts. Use the selected account's - // routable credential instead. + // Selected-account credentials are authoritative regardless of the global source. Claude CLI usage is + // ambient to the active local profile and must never be labeled as a configured token account. switch routing { case .adminAPIKey: return .api diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 51ffee53e2..f9c78fa1d1 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -1,4 +1,6 @@ #if os(macOS) +import Darwin +import Foundation import LocalAuthentication import Security #endif @@ -143,6 +145,14 @@ public enum KeychainAccessPreflight { let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: + guard let item = self.keychainItem(fromPreflightResult: result), + self.decryptACLAllowsCurrentProcess(item: item) + else { + self.log.info( + "Keychain preflight requires interaction for the current process", + metadata: ["service": service]) + return .interactionRequired + } self.log.debug("Keychain preflight allowed", metadata: ["service": service]) return .allowed case errSecItemNotFound: @@ -173,9 +183,10 @@ public enum KeychainAccessPreflight { kSecAttrService as String: service, kSecMatchLimit as String: kSecMatchLimitOne, // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations have been observed to show the legacy keychain prompt unless the query - // is strictly non-interactive. + // some macOS configurations have been observed to show the legacy keychain prompt even with UI-fail. + // The item reference lets us inspect its decrypt ACL before deciding whether a data query is safe. kSecReturnAttributes as String: true, + kSecReturnRef as String: true, ] KeychainNoUIQuery.apply(to: &query) if let account { @@ -183,5 +194,118 @@ public enum KeychainAccessPreflight { } return query } + + static func decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: [Bool]?, + promptSelector: SecKeychainPromptSelector) -> Bool + { + // Any non-zero selector can require authentication based on the caller's signature state. + // A background preflight cannot prove that condition safe, so fail closed. + guard promptSelector.rawValue == 0 else { return false } + // A nil application list means the ACL does not restrict callers. For an explicit list, at least one + // stored code-signing requirement must validate against the invoking executable. A path match alone is + // insufficient: legacy ACLs can retain an old build's signature at the same path and still show UI. + guard let trustedApplicationValidationResults else { return true } + return trustedApplicationValidationResults.contains(true) + } + + private static func keychainItem(fromPreflightResult result: AnyObject?) -> SecKeychainItem? { + guard let attributes = result as? [String: Any], + let value = attributes[kSecValueRef as String] + else { return nil } + return unsafeDowncast(value as AnyObject, to: SecKeychainItem.self) + } + + private static func decryptACLAllowsCurrentProcess(item: SecKeychainItem) -> Bool { + guard let copyItemAccess = self.securityFunction( + named: "SecKeychainItemCopyAccess", + as: SecKeychainItemCopyAccessFunction.self), + let copyMatchingACLs = self.securityFunction( + named: "SecAccessCopyMatchingACLList", + as: SecAccessCopyMatchingACLListFunction.self), + let copyACLContents = self.securityFunction( + named: "SecACLCopyContents", + as: SecACLCopyContentsFunction.self) + else { return false } + + var access: SecAccess? + guard copyItemAccess(item, &access) == errSecSuccess, + let access, + let rawACLs = copyMatchingACLs(access, kSecACLAuthorizationDecrypt)?.takeRetainedValue(), + let acls = rawACLs as? [SecACL], + !acls.isEmpty + else { return false } + + let currentPaths = KeychainCacheStore.invokingApplicationPathsForCacheAccess() + guard !currentPaths.isEmpty else { return false } + + for acl in acls { + var applications: CFArray? + var description: CFString? + var selector = SecKeychainPromptSelector() + guard copyACLContents(acl, &applications, &description, &selector) == errSecSuccess else { + continue + } + guard let applications else { + if self.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: nil, + promptSelector: selector) + { + return true + } + continue + } + guard let trustedApplications = applications as? [SecTrustedApplication] else { continue } + let validationResults = trustedApplications.flatMap { application in + currentPaths.map { currentPath in + self.trustedApplication(application, validatesExecutableAt: currentPath) + } + } + if self.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: validationResults, + promptSelector: selector) + { + return true + } + } + return false + } + + static func trustedApplication( + _ application: SecTrustedApplication, + validatesExecutableAt path: String) -> Bool + { + guard let validate = self.securityFunction( + named: "SecTrustedApplicationValidateWithPath", + as: SecTrustedApplicationValidateWithPathFunction.self) + else { return false } + return path.withCString { validate(application, $0) == errSecSuccess } + } + + private typealias SecKeychainItemCopyAccessFunction = @convention(c) ( + SecKeychainItem, + UnsafeMutablePointer) -> OSStatus + private typealias SecAccessCopyMatchingACLListFunction = @convention(c) ( + SecAccess, + CFTypeRef) -> Unmanaged? + private typealias SecACLCopyContentsFunction = @convention(c) ( + SecACL, + UnsafeMutablePointer, + UnsafeMutablePointer, + UnsafeMutablePointer) -> OSStatus + private typealias SecTrustedApplicationValidateWithPathFunction = @convention(c) ( + SecTrustedApplication, + UnsafePointer) -> OSStatus + + private nonisolated(unsafe) static let securityFrameworkHandle: UnsafeMutableRawPointer? = dlopen( + "/System/Library/Frameworks/Security.framework/Security", + RTLD_NOW) + + private static func securityFunction(named name: String, as _: T.Type) -> T? { + guard let securityFrameworkHandle, + let symbol = dlsym(securityFrameworkHandle, name) + else { return nil } + return unsafeBitCast(symbol, to: T.self) + } #endif } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index eb4d84fcc3..51d8595f71 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -43,6 +43,7 @@ public enum KeychainCacheStore { private static let cacheLabel = "CodexBar Cache" @TaskLocal private static var serviceOverride: String? @TaskLocal private static var forceImplicitTestStore = false + @TaskLocal private static var forceRealKeychainPath = false #if DEBUG @TaskLocal private static var operationRecorder: OperationRecorder? @@ -95,7 +96,8 @@ public enum KeychainCacheStore { return self.loadResultForKeychainReadFailure(status: status, key: key) } #endif - if let testResult = loadFromTestStore(key: key, as: type), + if !self.forceRealKeychainPath, + let testResult = loadFromTestStore(key: key, as: type), !self.prefersDisabledAccessMemoryStoreOverTestStore { return testResult @@ -105,6 +107,23 @@ public enum KeychainCacheStore { } guard self.canUseRealKeychain else { return .missing } #if os(macOS) + // Requesting secret bytes can surface a legacy ACL prompt even when the query carries + // `kSecUseAuthenticationUIFail`. Probe attributes and the item reference first, then ask + // for data only when the decrypt ACL already trusts this exact executable without UI. + switch KeychainAccessPreflight.checkGenericPassword( + service: self.serviceName, + account: key.account) + { + case .allowed: + break + case .interactionRequired: + self.log.info("Keychain cache item is unusable by this executable (\(key.account)); treating as missing") + return .missing + case .notFound: + return .missing + case let .failure(status): + return self.loadResultForKeychainReadFailure(status: OSStatus(status), key: key) + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, @@ -151,7 +170,8 @@ public enum KeychainCacheStore { return false } #endif - if !self.prefersDisabledAccessMemoryStoreOverTestStore, + if !self.forceRealKeychainPath, + !self.prefersDisabledAccessMemoryStoreOverTestStore, let stored = self.storeInTestStore(key: key, entry: entry) { return stored @@ -167,6 +187,20 @@ public enum KeychainCacheStore { return false } + let preflight = KeychainAccessPreflight.checkGenericPassword( + service: self.serviceName, + account: key.account) + switch preflight { + case .allowed, .notFound: + break + case .interactionRequired: + self.log.info("Keychain cache store requires interaction (\(key.account)); skipping") + return false + case let .failure(status): + self.log.error("Keychain cache store preflight failed (\(key.account)): \(status)") + return false + } + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, @@ -174,15 +208,17 @@ public enum KeychainCacheStore { ] KeychainNoUIQuery.apply(to: &query) - let updateStatus = KeychainSecurity.update( - query as CFDictionary, - [kSecValueData as String: data] as CFDictionary) - if updateStatus == errSecSuccess { - return true - } - if updateStatus != errSecItemNotFound { - self.log.error("Keychain cache update failed (\(key.account)): \(updateStatus)") - return false + if case .allowed = preflight { + let updateStatus = KeychainSecurity.update( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary) + if updateStatus == errSecSuccess { + return true + } + if updateStatus != errSecItemNotFound { + self.log.error("Keychain cache update failed (\(key.account)): \(updateStatus)") + return false + } } var addQuery = query @@ -194,6 +230,17 @@ public enum KeychainCacheStore { } let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) + if addStatus == errSecDuplicateItem { + // Another first-party process may have inserted the same cache item after our missing preflight. + // Revalidate its ACL before resolving the benign race with an update. + guard case .allowed = KeychainAccessPreflight.checkGenericPassword( + service: self.serviceName, + account: key.account) + else { return false } + return KeychainSecurity.update( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary) == errSecSuccess + } if addStatus != errSecSuccess { self.log.error("Keychain cache add failed (\(key.account)): \(addStatus)") } @@ -217,7 +264,8 @@ public enum KeychainCacheStore { return self.clearResultForKeychainDeleteStatus(status, key: key) } #endif - if !self.prefersDisabledAccessMemoryStoreOverTestStore, + if !self.forceRealKeychainPath, + !self.prefersDisabledAccessMemoryStoreOverTestStore, let removed = self.clearTestStore(key: key) { return removed ? .removed : .missing @@ -227,6 +275,21 @@ public enum KeychainCacheStore { } guard self.canUseRealKeychain else { return .failed } #if os(macOS) + switch KeychainAccessPreflight.checkGenericPassword( + service: self.serviceName, + account: key.account) + { + case .allowed: + break + case .notFound: + return .missing + case .interactionRequired: + self.log.info("Keychain cache delete requires interaction (\(key.account)); skipping") + return .failed + case let .failure(status): + self.log.error("Keychain cache delete preflight failed (\(key.account)): \(status)") + return .failed + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, @@ -254,7 +317,8 @@ public enum KeychainCacheStore { return self.keysResultForKeychainStatus(status, category: category, result: nil) } #endif - if !self.prefersDisabledAccessMemoryStoreOverTestStore, + if !self.forceRealKeychainPath, + !self.prefersDisabledAccessMemoryStoreOverTestStore, let keys = self.keysFromTestStore(category: category) { return .found(keys) @@ -330,6 +394,14 @@ public enum KeychainCacheStore { } #if DEBUG + static func withRealKeychainPathForTesting( + operation: () throws -> T) rethrows -> T + { + try self.$forceRealKeychainPath.withValue(true) { + try operation() + } + } + static func withOperationRecorderForTesting( _ recorder: OperationRecorder?, operation: () throws -> T) rethrows -> T @@ -371,6 +443,15 @@ public enum KeychainCacheStore { } } + public static func withLoadFailureStatusOverrideForTesting( + _ status: OSStatus?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$loadFailureStatusOverride.withValue(status) { + try await operation() + } + } + public static func withStoreFailureStatusOverrideForTesting( _ status: OSStatus?, operation: () throws -> T) rethrows -> T @@ -600,6 +681,16 @@ public enum KeychainCacheStore { return paths } + /// The caller that will perform the secret-data operation after preflight. The cache ACL may trust + /// multiple first-party executables, but one executable cannot authorize access on another's behalf. + static func invokingApplicationPathsForCacheAccess( + executableURL: URL? = Bundle.main.executableURL, + fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> [String] + { + guard let path = executableURL?.path, !path.isEmpty, fileExists(path) else { return [] } + return [path] + } + private static func appBundleURL(containing url: URL) -> URL? { var current = url.standardizedFileURL while current.path != "/" { @@ -642,7 +733,7 @@ public enum KeychainCacheStore { CFArray, UnsafeMutablePointer?) -> OSStatus - private static func createTrustedApplication(path: String) -> (OSStatus, SecTrustedApplication?) { + static func createTrustedApplication(path: String) -> (OSStatus, SecTrustedApplication?) { guard let symbol = self.securitySymbol(named: "SecTrustedApplicationCreateFromPath") else { return (errSecInternalComponent, nil) } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift new file mode 100644 index 0000000000..9e2c618f8b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift @@ -0,0 +1,66 @@ +import Foundation + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// Prompt-free Claude profile identity derived only from Claude-owned plain-text configuration. +public enum ClaudeAccountProfile { + private struct ClaudeConfigAccount: Decodable { + struct OAuthAccount: Decodable { + let accountUuid: String? + } + + let oauthAccount: OAuthAccount? + } + + public static func accountUuid(environment: [String: String]) -> String? { + let url = ClaudeConfigPaths.accountConfigURL(environment: environment) + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data), + let uuid = decoded.oauthAccount?.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), + !uuid.isEmpty + else { + return nil + } + return uuid + } + + /// A process-local ownership key for Claude TUI reuse. Missing identity fails closed with a fresh scope. + public static func sessionScope( + environment: [String: String], + fallbackID: UUID = UUID()) -> String + { + self.makeSessionScope( + environment: environment, + identity: self.accountUuid(environment: environment), + fallbackID: fallbackID) + } + + /// A stable ownership key only when Claude's selected profile exposes an account identity. + /// Background work must fail closed rather than let an unidentified profile inherit another profile's marker. + static func identifiedSessionScope(environment: [String: String]) -> String? { + guard let identity = self.accountUuid(environment: environment) else { return nil } + return self.makeSessionScope(environment: environment, identity: identity, fallbackID: UUID()) + } + + private static func makeSessionScope( + environment: [String: String], + identity: String?, + fallbackID: UUID) -> String + { + let accountConfigPath = ClaudeConfigPaths.accountConfigURL(environment: environment).path + let credentialsPath = ClaudeConfigPaths.credentialsURL(environment: environment).path + let identity = identity?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let material = if let identity, !identity.isEmpty { + "claude:cli-session:v2:\(accountConfigPath):\(credentialsPath):\(identity)" + } else { + "claude:cli-session-ephemeral:v2:\(accountConfigPath):\(credentialsPath):" + + fallbackID.uuidString.lowercased() + } + let digest = SHA256.hash(data: Data(material.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift index 4149481d9b..7f7dd4e593 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift @@ -29,18 +29,23 @@ enum ClaudeCLIAuthStatusProbe { static func isLoggedIn( binary: String, environment: [String: String], + workingDirectory: URL? = nil, timeout: TimeInterval = 5) async -> Bool { if let resultOverrideForTesting = self.resultOverrideForTesting { return resultOverrideForTesting } do { + let workingDirectory = workingDirectory ?? ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() + var launchEnvironment = ClaudeCLISession.launchEnvironment(baseEnv: environment) + launchEnvironment["PWD"] = workingDirectory.path let result = try await SubprocessRunner.run( binary: binary, arguments: ["auth", "status", "--json"], - environment: ClaudeCLISession.launchEnvironment(baseEnv: environment), + environment: launchEnvironment, timeout: self.timeoutOverrideForTesting ?? timeout, standardInput: FileHandle.nullDevice, + currentDirectoryURL: workingDirectory, label: "claude-auth-status") return self.parseLoggedIn(result.stdout) } catch { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 2b0bc9a5ee..f131b0c1fc 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -7,6 +7,49 @@ import Musl #endif import Foundation +private actor ClaudeCLISessionOperationGate { + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private var ownerID: UUID? + private var waiters: [Waiter] = [] + + func acquire(id: UUID, rejectIfCancelled: Bool) async -> Bool { + if rejectIfCancelled, Task.isCancelled { + return false + } + guard self.ownerID != nil else { + self.ownerID = id + return true + } + return await withCheckedContinuation { continuation in + self.waiters.append(Waiter(id: id, continuation: continuation)) + } + } + + func cancel(id: UUID) { + if self.ownerID == id { + return + } + guard let index = self.waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = self.waiters.remove(at: index) + waiter.continuation.resume(returning: false) + } + + func release(id: UUID) { + guard self.ownerID == id else { return } + guard !self.waiters.isEmpty else { + self.ownerID = nil + return + } + let waiter = self.waiters.removeFirst() + self.ownerID = waiter.id + waiter.continuation.resume(returning: true) + } +} + actor ClaudeCLISession { static let shared = ClaudeCLISession() private static let log = CodexBarLog.logger(LogCategories.claudeCLI) @@ -50,13 +93,33 @@ actor ClaudeCLISession { } } + private struct SessionIdentity: Equatable { + let binaryPath: String + let accountScope: String? + let environment: [String: String] + } + + private struct CaptureRequest { + let subcommand: String + let binary: String + let accountScope: String? + let timeout: TimeInterval + let environment: [String: String] + let idleTimeout: TimeInterval? + let stopOnSubstrings: [String] + let stopWhenNormalized: (@Sendable (String) -> Bool)? + let settleAfterStop: TimeInterval + let sendEnterEvery: TimeInterval? + } + private var process: Process? private var primaryFD: Int32 = -1 private var primaryHandle: FileHandle? private var secondaryHandle: FileHandle? private var processGroup: pid_t? - private var binaryPath: String? + private var sessionIdentity: SessionIdentity? private var startedAt: Date? + private let operationGate = ClaudeCLISessionOperationGate() private let promptSends: [String: String] = [ "Do you trust the files in this folder?": "y\r", @@ -120,14 +183,57 @@ actor ClaudeCLISession { func capture( subcommand: String, binary: String, + accountScope: String? = nil, timeout: TimeInterval, + environment: [String: String] = ProcessInfo.processInfo.environment, idleTimeout: TimeInterval? = 3.0, stopOnSubstrings: [String] = [], stopWhenNormalized: (@Sendable (String) -> Bool)? = nil, settleAfterStop: TimeInterval = 0.25, sendEnterEvery: TimeInterval? = nil) async throws -> String { - try self.ensureStarted(binary: binary) + let operationID = UUID() + let acquired = await withTaskCancellationHandler { + await self.operationGate.acquire(id: operationID, rejectIfCancelled: true) + } onCancel: { + Task { await self.operationGate.cancel(id: operationID) } + } + guard acquired else { throw CancellationError() } + + do { + try Task.checkCancellation() + let output = try await self.captureExclusive(request: CaptureRequest( + subcommand: subcommand, + binary: binary, + accountScope: accountScope, + timeout: timeout, + environment: environment, + idleTimeout: idleTimeout, + stopOnSubstrings: stopOnSubstrings, + stopWhenNormalized: stopWhenNormalized, + settleAfterStop: settleAfterStop, + sendEnterEvery: sendEnterEvery)) + await self.operationGate.release(id: operationID) + return output + } catch { + await self.operationGate.release(id: operationID) + throw error + } + } + + private func captureExclusive(request: CaptureRequest) async throws -> String { + let subcommand = request.subcommand + let binary = request.binary + let accountScope = request.accountScope + let timeout = request.timeout + let environment = request.environment + let idleTimeout = request.idleTimeout + let stopOnSubstrings = request.stopOnSubstrings + let stopWhenNormalized = request.stopWhenNormalized + let settleAfterStop = request.settleAfterStop + let sendEnterEvery = request.sendEnterEvery + + try self.ensureStarted(binary: binary, accountScope: accountScope, environment: environment) if let startedAt { let sinceStart = Date().timeIntervalSince(startedAt) // Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the @@ -274,12 +380,23 @@ actor ClaudeCLISession { utf8Carry = Data(combined.suffix(12)) } - func reset() { + func reset() async { + let operationID = UUID() + _ = await self.operationGate.acquire(id: operationID, rejectIfCancelled: false) self.cleanup() + await self.operationGate.release(id: operationID) } - private func ensureStarted(binary: String) throws { - if let proc = self.process, proc.isRunning, self.binaryPath == binary { + private func ensureStarted( + binary: String, + accountScope: String?, + environment: [String: String]) throws + { + let sessionIdentity = SessionIdentity( + binaryPath: binary, + accountScope: accountScope, + environment: Self.launchEnvironment(baseEnv: environment)) + if let proc = self.process, proc.isRunning, self.sessionIdentity == sessionIdentity { Self.log.debug("Claude CLI session reused") return } @@ -302,10 +419,12 @@ actor ClaudeCLISession { let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() // A crashed probe can leave a JSONL behind. Claude treats `--session-id` as creation-only when that local // transcript exists, so clear the probe-owned artifact before reusing the account-side identifier. - ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts(probeDirectory: workingDirectory) + ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: workingDirectory, + environment: sessionIdentity.environment) let sessionID = Self.loadOrCreateProbeSessionID(in: workingDirectory) let claudeArguments = Self.launchArguments(sessionID: sessionID) - let disableWatchdog = ProcessInfo.processInfo.environment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] == "1" + let disableWatchdog = sessionIdentity.environment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] == "1" if !disableWatchdog, resolvedURL.lastPathComponent == "claude", let watchdog = TTYCommandRunner.locateBundledHelper("CodexBarClaudeWatchdog") @@ -321,7 +440,7 @@ actor ClaudeCLISession { proc.standardError = secondaryHandle proc.currentDirectoryURL = workingDirectory - var env = Self.launchEnvironment() + var env = sessionIdentity.environment env["PWD"] = workingDirectory.path proc.environment = env @@ -367,14 +486,15 @@ actor ClaudeCLISession { self.primaryHandle = primaryHandle self.secondaryHandle = secondaryHandle self.processGroup = processGroup - self.binaryPath = binary + self.sessionIdentity = sessionIdentity self.startedAt = Date() } static func launchArguments(sessionID: UUID) -> [String] { // `/usage` is interactive, while Claude's no-persistence option is print-only. Reusing one explicit ID keeps - // repeated probe launches from registering a fresh empty account session every time. - ["--allowed-tools", "", "--session-id", sessionID.uuidString.lowercased()] + // repeated probe launches from registering a fresh empty account session every time. The probe never uses MCP + // tools, so ignore ambient MCP configuration rather than waiting for unrelated user servers to initialize. + ["--allowed-tools", "", "--strict-mcp-config", "--session-id", sessionID.uuidString.lowercased()] } static func loadOrCreateProbeSessionID( @@ -486,6 +606,7 @@ actor ClaudeCLISession { self.secondaryHandle = nil self.primaryFD = -1 self.processGroup = nil + self.sessionIdentity = nil self.startedAt = nil } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 5cd5885105..0bc37ce96f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -93,6 +93,9 @@ extension ClaudeOAuthCredentialsStore { -> Data? { guard self.shouldPreferSecurityCLIKeychainRead(readStrategy: readStrategy) else { return nil } + // `/usr/bin/security` is not constrained by Security.framework's no-UI flags. Keep the ownership gate at + // the process-launch boundary so no caller can bypass it by selecting the experimental reader. + guard self.keychainAccessAllowed else { return nil } guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return nil } let interactionMetadata = interaction == .userInitiated ? "user" : "background" @@ -383,4 +386,20 @@ extension ClaudeOAuthCredentialsStore { guard let payload else { return false } return ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: payload) } + + static func shouldBlockSelectedProfileForMcpOnlyClaudeKeychain( + interaction: ProviderInteraction, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current(), + keychainAccessDisabled: Bool = KeychainAccessGate.isDisabled, + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + // The global Keychain item has no profile identity. It can diagnose a missing selected profile, + // but cannot veto an OAuth credentials file attributable to that profile. + guard !self.hasSelectedProfileOAuthCredentialsFile(environment: environment) else { return false } + return self.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: interaction, + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled, + environment: environment) + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index c4aeeddf1b..a4cb1dcaf7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -2,6 +2,11 @@ import Foundation #if DEBUG extension ClaudeOAuthCredentialsStore { + /// Mirrors the production ownership decision without installing a synthetic credential fixture. + static var directClaudeCodeKeychainAccessAllowedForTesting: Bool { + self.keychainAccessAllowed + } + @TaskLocal static var taskBeforeClaudeKeychainPromptLockOverride: (@Sendable () -> Void)? @TaskLocal static var taskInteractiveClaudeKeychainReadOverride: (@Sendable () throws -> Data)? @@ -243,6 +248,7 @@ extension ClaudeOAuthCredentialsStore { final class CredentialsFileFingerprintStore: @unchecked Sendable { private var fingerprints: [String: CredentialsFileFingerprint] = [:] + private var quarantines: [String: CredentialsFileFingerprint] = [:] private var legacyFingerprint: CredentialsFileFingerprint? init(fingerprint: CredentialsFileFingerprint? = nil) { @@ -280,8 +286,17 @@ extension ClaudeOAuthCredentialsStore { self.fingerprints[profileIdentifier] = fingerprint } + func loadQuarantine(profileIdentifier: String) -> CredentialsFileFingerprint? { + self.quarantines[profileIdentifier] + } + + func saveQuarantine(_ fingerprint: CredentialsFileFingerprint?, profileIdentifier: String) { + self.quarantines[profileIdentifier] = fingerprint + } + func reset() { self.fingerprints.removeAll() + self.quarantines.removeAll() self.legacyFingerprint = nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 7f51a1a991..21fb9cec0b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -38,6 +38,7 @@ public enum ClaudeOAuthCredentialsStore { /// historical default credentials profile and is migrated lazily to that profile. private static let legacyFileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV2" private static let fileFingerprintProfileSeparator = ".profile." + private static let credentialsFileQuarantineKeyPrefix = "ClaudeOAuthCredentialsFileQuarantineV1.profile." private static let claudeKeychainPromptLock = NSLock() private enum PromptAttemptResult { case record(ClaudeOAuthCredentialRecord) @@ -279,11 +280,11 @@ public enum ClaudeOAuthCredentialsStore { environment: [String: String], allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool, - allowClaudeKeychainRepairWithoutPrompt: Bool) throws -> ClaudeOAuthCredentialRecord + allowClaudeKeychainRepairWithoutPrompt: Bool, + clearInvalidCache: Bool = true) throws -> ClaudeOAuthCredentialRecord { try self.context.run { - let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( - environment: environment) + let profileIdentifier = self.prepareCachePolicy(environment: environment) let shouldRespectKeychainPromptCooldownForSilentProbes = respectKeychainPromptCooldown || !allowKeychainPrompt @@ -302,7 +303,10 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - let owner = self.resolvedCacheOwner(cachedRecord.owner, environment: environment) + let owner = self.resolvedCacheOwner( + cachedRecord.owner, + credentials: cachedRecord.credentials, + environment: environment) let record = ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, owner: owner, @@ -326,8 +330,12 @@ public enum ClaudeOAuthCredentialsStore { profileIdentifier: profileIdentifier) { case let .found(entry): - if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { - let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI, environment: environment) + do { + let creds = try ClaudeOAuthCredentials.parse(data: entry.data) + let owner = self.resolvedCacheOwner( + entry.owner ?? .claudeCLI, + credentials: creds, + environment: environment) let record = ClaudeOAuthCredentialRecord( credentials: creds, owner: owner, @@ -353,13 +361,20 @@ public enum ClaudeOAuthCredentialsStore { profileIdentifier: profileIdentifier) return record } - } else { - ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) + } catch { + lastError = self.handleInvalidCache( + error, + profileIdentifier: profileIdentifier, + clearInvalidCache: clearInvalidCache) } case .invalid: - ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) + lastError = self.handleInvalidCache( + ClaudeOAuthCredentialsError.decodeFailed, + profileIdentifier: profileIdentifier, + clearInvalidCache: clearInvalidCache) case .temporarilyUnavailable: cacheTemporarilyUnavailable = true + lastError = ClaudeOAuthCredentialsError.readFailed("CodexBar cache is temporarily unavailable.") case .missing: break } @@ -428,6 +443,27 @@ public enum ClaudeOAuthCredentialsStore { } } + private func prepareCachePolicy(environment: [String: String]) -> String { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + if !ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache { + ClaudeOAuthCredentialsStore.markPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier) + } + return profileIdentifier + } + + private func handleInvalidCache( + _ error: Error, + profileIdentifier: String, + clearInvalidCache: Bool) -> Error? + { + if clearInvalidCache { + ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) + return nil + } + return error + } + private func immediateCredentialRecord(environment: [String: String]) throws -> ClaudeOAuthCredentialRecord? { if let credentials = ClaudeOAuthCredentialsStore.loadFromEnvironment(environment) { return ClaudeOAuthCredentialRecord( @@ -561,7 +597,10 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - let owner = self.resolvedCacheOwner(cachedRecord.owner, environment: environment) + let owner = self.resolvedCacheOwner( + cachedRecord.owner, + credentials: cachedRecord.credentials, + environment: environment) return ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, owner: owner, @@ -575,7 +614,10 @@ public enum ClaudeOAuthCredentialsStore { else { return nil } - let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI, environment: environment) + let owner = self.resolvedCacheOwner( + entry.owner ?? .claudeCLI, + credentials: credentials, + environment: environment) return ClaudeOAuthCredentialRecord( credentials: credentials, owner: owner, @@ -678,20 +720,31 @@ public enum ClaudeOAuthCredentialsStore { private func resolvedCacheOwner( _ owner: ClaudeOAuthCredentialOwner, + credentials: ClaudeOAuthCredentials, environment: [String: String]) -> ClaudeOAuthCredentialOwner { guard owner == .codexbar else { return owner } - guard self.hasClaudeCLIStorageWithoutPrompt(environment: environment) else { return owner } - // Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle. + guard self.hasClaudeCLIStorageWithoutPrompt( + matching: credentials, + environment: environment) + else { return owner } + // Claude Code rotates refresh tokens; selected-profile file evidence or a matching global + // Keychain credential proves that it owns this cache's refresh lifecycle. return .claudeCLI } - private func hasClaudeCLIStorageWithoutPrompt(environment: [String: String]) -> Bool { + private func hasClaudeCLIStorageWithoutPrompt( + matching credentials: ClaudeOAuthCredentials, + environment: [String: String]) -> Bool + { if ClaudeOAuthCredentialsStore.currentFileFingerprint(environment: environment) != nil { return true } guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return false } - return ClaudeOAuthCredentialsStore.hasClaudeKeychainItemWithoutPrompt() + guard case .matched = ClaudeOAuthCredentialsStore.claudeKeychainCredentialMatchWithoutPrompt( + for: credentials) + else { return false } + return true } @discardableResult @@ -1323,7 +1376,7 @@ public enum ClaudeOAuthCredentialsStore { historyOwnerIdentifier: historyOwnerIdentifier), timestamp: Date(), profileIdentifier: self.profileIdentifier) - ClaudeOAuthRefreshFailureGate.recordSuccess() + ClaudeOAuthRefreshFailureGate.recordSuccess(environment: self.environment) return newCredentials } @@ -1335,8 +1388,8 @@ public enum ClaudeOAuthCredentialsStore { existingRateLimitTier: String?, existingSubscriptionType: String?) async throws -> ClaudeOAuthCredentials { - guard ClaudeOAuthRefreshFailureGate.shouldAttempt() else { - let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus() + guard ClaudeOAuthRefreshFailureGate.shouldAttempt(environment: self.environment) else { + let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus(environment: self.environment) let message = switch status { case .terminal: "Claude OAuth refresh blocked until auth changes. \(ClaudeOAuthCredentialsStore.reauthenticateHint)" @@ -1384,14 +1437,14 @@ public enum ClaudeOAuthCredentialsStore { switch disposition { case .terminalInvalidGrant: - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure() + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(environment: self.environment) Repository(context: self.context).invalidateCache(environment: self.environment) let message = "HTTP \(response.statusCode) invalid_grant. " + ClaudeOAuthCredentialsStore.reauthenticateHint throw ClaudeOAuthCredentialsError.refreshFailed( message) case .transientBackoff: - ClaudeOAuthRefreshFailureGate.recordTransientFailure() + ClaudeOAuthRefreshFailureGate.recordTransientFailure(environment: self.environment) let suffix = oauthError.map { " (\($0))" } ?? "" throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)\(suffix)") } @@ -1428,14 +1481,16 @@ public enum ClaudeOAuthCredentialsStore { environment: [String: String] = ProcessInfo.processInfo.environment, allowKeychainPrompt: Bool = true, respectKeychainPromptCooldown: Bool = false, - allowClaudeKeychainRepairWithoutPrompt: Bool = true) throws -> ClaudeOAuthCredentialRecord + allowClaudeKeychainRepairWithoutPrompt: Bool = true, + clearInvalidCache: Bool = true) throws -> ClaudeOAuthCredentialRecord { let context = self.currentCollaboratorContext() return try Repository(context: context).loadRecord( environment: environment, allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, - allowClaudeKeychainRepairWithoutPrompt: allowClaudeKeychainRepairWithoutPrompt) + allowClaudeKeychainRepairWithoutPrompt: allowClaudeKeychainRepairWithoutPrompt, + clearInvalidCache: clearInvalidCache) } /// Async version of load that handles expired tokens based on credential ownership. @@ -1456,7 +1511,9 @@ public enum ClaudeOAuthCredentialsStore { public static func loadRecordWithAutoRefresh( environment: [String: String] = ProcessInfo.processInfo.environment, allowKeychainPrompt: Bool = true, - respectKeychainPromptCooldown: Bool = false) async throws -> ClaudeOAuthCredentialRecord + respectKeychainPromptCooldown: Bool = false, + allowClaudeKeychainRepairWithoutPrompt: Bool = true, + clearInvalidCache: Bool = true) async throws -> ClaudeOAuthCredentialRecord { let context = self.currentCollaboratorContext() let repository = Repository(context: context) @@ -1468,7 +1525,8 @@ public enum ClaudeOAuthCredentialsStore { environment: environment, allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, - allowClaudeKeychainRepairWithoutPrompt: true) + allowClaudeKeychainRepairWithoutPrompt: allowClaudeKeychainRepairWithoutPrompt, + clearInvalidCache: clearInvalidCache) let credentials = record.credentials let now = Date() var expiryMetadata = credentials.diagnosticsMetadata(now: now) @@ -1495,7 +1553,7 @@ public enum ClaudeOAuthCredentialsStore { switch record.owner { case .claudeCLI: if ProviderInteractionContext.current != .userInitiated, - ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + ClaudeOAuthCredentialsStore.shouldBlockSelectedProfileForMcpOnlyClaudeKeychain( interaction: ProviderInteractionContext.current, environment: environment) { @@ -1596,6 +1654,9 @@ public enum ClaudeOAuthCredentialsStore { public static func loadFromFile( environment: [String: String] = ProcessInfo.processInfo.environment) throws -> Data { + guard !self.isCurrentCredentialsFileQuarantinedForOAuth(environment: environment) else { + throw ClaudeOAuthCredentialsError.notFound + } let url = self.credentialsFileURL(environment: environment) do { return try Data(contentsOf: url) @@ -1607,6 +1668,11 @@ public enum ClaudeOAuthCredentialsStore { } } + static func hasSelectedProfileOAuthCredentialsFile(environment: [String: String]) -> Bool { + guard let data = try? self.loadFromFile(environment: environment) else { return false } + return (try? ClaudeOAuthCredentials.parse(data: data)) != nil + } + public static func credentialsFileFingerprintToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -1615,6 +1681,38 @@ public enum ClaudeOAuthCredentialsStore { return "\(fingerprint.path):\(modifiedAt):\(fingerprint.size)" } + /// Rejects the selected profile's current credentials file until its fingerprint changes. This prevents an + /// account-mismatched OAuth record from re-entering through the file after its memory/Keychain cache is cleared. + @discardableResult + public static func quarantineCurrentCredentialsFileForOAuth( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let profileIdentifier = self.credentialsProfileIdentifier(environment: environment) + guard let fingerprint = self.currentFileFingerprint(environment: environment) else { + self.saveQuarantinedCredentialsFileFingerprint(nil, profileIdentifier: profileIdentifier) + return false + } + self.saveQuarantinedCredentialsFileFingerprint(fingerprint, profileIdentifier: profileIdentifier) + return true + } + + /// Returns true only while the selected profile still exposes the exact file fingerprint that was rejected. + /// A rewrite, removal, or profile switch automatically releases the quarantine. + public static func isCurrentCredentialsFileQuarantinedForOAuth( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let profileIdentifier = self.credentialsProfileIdentifier(environment: environment) + guard let quarantined = self.loadQuarantinedCredentialsFileFingerprint( + profileIdentifier: profileIdentifier) + else { return false } + let current = self.currentFileFingerprint(environment: environment) + guard current == quarantined else { + self.saveQuarantinedCredentialsFileFingerprint(nil, profileIdentifier: profileIdentifier) + return false + } + return true + } + public static func authFingerprintToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String { @@ -1686,6 +1784,12 @@ public enum ClaudeOAuthCredentialsStore { for record: ClaudeOAuthCredentialRecord) -> ClaudeKeychainCredentialMatch { guard record.owner == .claudeCLI else { return .notApplicable } + return self.claudeKeychainCredentialMatchWithoutPrompt(for: record.credentials) + } + + private static func claudeKeychainCredentialMatchWithoutPrompt( + for credentials: ClaudeOAuthCredentials) -> ClaudeKeychainCredentialMatch + { let evidence: ClaudeKeychainCredentialEvidence switch self.newestClaudeKeychainCredentialEvidenceWithoutPrompt() { case .unavailable: @@ -1695,7 +1799,7 @@ public enum ClaudeOAuthCredentialsStore { case let .value(value?): evidence = value } - guard evidence.credentials.accessToken == record.credentials.accessToken else { + guard evidence.credentials.accessToken == credentials.accessToken else { return .mismatch } return .matched(persistentRefHash: evidence.persistentRefHash) @@ -1833,44 +1937,6 @@ public enum ClaudeOAuthCredentialsStore { Repository(context: self.currentCollaboratorContext()).hasClaudeKeychainCredentialsWithoutPrompt() } - private static func hasClaudeKeychainItemWithoutPrompt() -> Bool { - #if DEBUG - if let store = self.taskClaudeKeychainOverrideStore { - if let data = store.data, !data.isEmpty { - return true - } - if store.fingerprint != nil { - return true - } - } - if let data = self.taskClaudeKeychainDataOverride, - !data.isEmpty - { - return true - } - if self.taskClaudeKeychainFingerprintOverride != nil { - return true - } - #endif - - #if os(macOS) - switch self.claudeKeychainCandidatesProbeWithoutPrompt(enforcePromptPolicy: false) { - case let .value(candidates) where !candidates.isEmpty: - return true - case .value, .unavailable: - break - } - switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(enforcePromptPolicy: false) { - case let .value(candidate): - return candidate != nil - case .unavailable: - return false - } - #else - return false - #endif - } - private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool { #if DEBUG // Unit tests can supply TaskLocal overrides for the Claude keychain data/fingerprint. Those tests often run @@ -1990,12 +2056,12 @@ public enum ClaudeOAuthCredentialsStore { self.currentClaudeKeychainFingerprintWithoutPrompt() } - static func currentCredentialsFileFingerprintWithoutPromptForAuthGate() -> String? { - guard let fingerprint = self.currentFileFingerprint(environment: ProcessInfo.processInfo.environment) else { - return nil - } + public static func currentCredentialsFileFingerprintWithoutPromptForAuthGate( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + guard let fingerprint = self.currentFileFingerprint(environment: environment) else { return nil } let modifiedAt = fingerprint.modifiedAtMs ?? 0 - return "\(modifiedAt):\(fingerprint.size)" + return "\(fingerprint.path):\(modifiedAt):\(fingerprint.size)" } private static func loadFromClaudeKeychainNonInteractive( @@ -2786,7 +2852,7 @@ public enum ClaudeOAuthCredentialsStore { return self.pendingCodexBarOAuthKeychainCacheClearStore } - private static var keychainAccessAllowed: Bool { + static var keychainAccessAllowed: Bool { #if DEBUG if let override = self.taskKeychainAccessOverride { return !override @@ -2798,7 +2864,10 @@ public enum ClaudeOAuthCredentialsStore { return true } #endif - return !KeychainAccessGate.isDisabled + // Claude Code owns `Claude Code-credentials` and rewrites the item during token refreshes. That rewrite + // replaces its ACL, so any permission granted to CodexBar is inherently temporary and causes recurring + // macOS password dialogs. Production CodexBar therefore never reads the foreign item, with or without UI. + return false } #if DEBUG @@ -2806,6 +2875,7 @@ public enum ClaudeOAuthCredentialsStore { self.taskClaudeKeychainOverrideStore != nil || self.taskClaudeKeychainDataOverride != nil || self.taskClaudeKeychainFingerprintOverride != nil + || self.taskInteractiveClaudeKeychainReadOverride != nil || self.taskSecurityCLIReadOverride != nil || self.taskSecurityCLIReadAccountOverride != nil } @@ -2855,10 +2925,7 @@ public enum ClaudeOAuthCredentialsStore { guard self.keychainAccessAllowed else { return false } switch mode { case .never: - // `.never` means "no interactive prompts", not "no Keychain access at all": a guaranteed - // no-UI read (KeychainNoUIQuery) must still be able to repair a missing credentials file - // from a valid Keychain item without ever surfacing a system prompt. - return !allowKeychainPrompt + return false case .onlyOnUserAction: return ProviderInteractionContext.current == .userInitiated case .always: return true @@ -2930,6 +2997,42 @@ public enum ClaudeOAuthCredentialsStore { self.legacyFileFingerprintKey + self.fileFingerprintProfileSeparator + profileIdentifier } + private static func credentialsFileQuarantineKey(profileIdentifier: String) -> String { + self.credentialsFileQuarantineKeyPrefix + profileIdentifier + } + + private static func loadQuarantinedCredentialsFileFingerprint( + profileIdentifier: String) -> CredentialsFileFingerprint? + { + #if DEBUG + if let store = self.taskCredentialsFileFingerprintStoreOverride { + return store.loadQuarantine(profileIdentifier: profileIdentifier) + } + #endif + guard let data = UserDefaults.standard.data( + forKey: self.credentialsFileQuarantineKey(profileIdentifier: profileIdentifier)) + else { return nil } + return try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data) + } + + private static func saveQuarantinedCredentialsFileFingerprint( + _ fingerprint: CredentialsFileFingerprint?, + profileIdentifier: String) + { + #if DEBUG + if let store = self.taskCredentialsFileFingerprintStoreOverride { + store.saveQuarantine(fingerprint, profileIdentifier: profileIdentifier) + return + } + #endif + let key = self.credentialsFileQuarantineKey(profileIdentifier: profileIdentifier) + guard let fingerprint, let data = try? JSONEncoder().encode(fingerprint) else { + UserDefaults.standard.removeObject(forKey: key) + return + } + UserDefaults.standard.set(data, forKey: key) + } + private static func loadFileFingerprint(profileIdentifier: String) -> CredentialsFileFingerprint? { #if DEBUG if let store = self.taskCredentialsFileFingerprintStoreOverride { @@ -3014,6 +3117,11 @@ public enum ClaudeOAuthCredentialsStore { { defaults.removeObject(forKey: key) } + for key in defaults.dictionaryRepresentation().keys + where key.hasPrefix(self.credentialsFileQuarantineKeyPrefix) + { + defaults.removeObject(forKey: key) + } } if self.taskPendingCacheClearStoreOverride != nil { self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index 4ac515677a..2d70d667b7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -1,14 +1,19 @@ import Foundation public enum ClaudeOAuthDelegatedRefreshCoordinator { + private struct CooldownState { + var lastAttemptAt: Date? + var interval: TimeInterval? + } + private final class AttemptStateStorage: @unchecked Sendable { let lock = NSLock() let persistsCooldown: Bool - var hasLoadedState = false - var lastAttemptAt: Date? - var lastCooldownInterval: TimeInterval? + var loadedCooldownProfiles: Set = [] + var cooldownByProfile: [String: CooldownState] = [:] var inFlightAttemptID: UInt64? var inFlightInteraction: ProviderInteraction? + var inFlightProfileIdentifier: String? var inFlightTask: Task? var nextAttemptID: UInt64 = 0 @@ -28,6 +33,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static let cooldownDefaultsKey = "claudeOAuthDelegatedRefreshLastAttemptAtV1" private static let cooldownIntervalDefaultsKey = "claudeOAuthDelegatedRefreshCooldownIntervalSecondsV1" + private static let cooldownProfileKeySeparator = ".profile." private static let defaultCooldownInterval: TimeInterval = 60 * 5 private static let shortCooldownInterval: TimeInterval = 20 @@ -51,6 +57,9 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { if case .joinThenRetry = decision { self.userInitiatedBackgroundJoinObserverForTesting?() } + if case .joinDifferentProfileThenRetry = decision { + self.differentProfileJoinObserverForTesting?() + } #endif switch decision { @@ -65,6 +74,10 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { case .attemptedSucceeded: return outcome } + case let .joinDifferentProfileThenRetry(id, task, state): + _ = await task.value + self.clearInFlightTaskIfStillCurrent(id: id, state: state) + return await self.attempt(now: now, timeout: timeout, environment: environment) case let .start(id, task, state): let outcome = await task.value self.clearInFlightTaskIfStillCurrent(id: id, state: state) @@ -75,15 +88,18 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private enum InFlightDecision { case join(Task) case joinThenRetry(UInt64, Task, AttemptStateStorage) + case joinDifferentProfileThenRetry(UInt64, Task, AttemptStateStorage) case start(UInt64, Task, AttemptStateStorage) } private struct AttemptConfiguration { let environment: [String: String] + let profileIdentifier: String let interaction: ProviderInteraction let readStrategy: ClaudeOAuthKeychainReadStrategy let promptMode: ClaudeOAuthKeychainPromptMode let keychainAccessDisabled: Bool + let hasSelectedProfileOAuthCredentialsFile: Bool #if DEBUG let cliAvailableOverride: Bool? let touchAuthPathOverride: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? @@ -98,10 +114,16 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { interaction: ProviderInteraction) -> InFlightDecision { let state = self.currentStateStorage + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) state.lock.lock() defer { state.lock.unlock() } if let existing = state.inFlightTask { + if state.inFlightProfileIdentifier != profileIdentifier, + let existingID = state.inFlightAttemptID + { + return .joinDifferentProfileThenRetry(existingID, existing, state) + } if interaction == .userInitiated, state.inFlightInteraction != .userInitiated, let existingID = state.inFlightAttemptID @@ -118,12 +140,15 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current() let configuration = AttemptConfiguration( environment: environment, + profileIdentifier: profileIdentifier, interaction: interaction, readStrategy: readStrategy, // The delegated Claude process is an opaque Keychain boundary. Its policy must come // from the user's stored preference, not the strategy-adjusted mode used by our own reads. promptMode: ClaudeOAuthKeychainPromptPreference.storedMode(), keychainAccessDisabled: KeychainAccessGate.isDisabled, + hasSelectedProfileOAuthCredentialsFile: ClaudeOAuthCredentialsStore + .hasSelectedProfileOAuthCredentialsFile(environment: environment), cliAvailableOverride: self.cliAvailableOverrideForTesting, touchAuthPathOverride: self.touchAuthPathOverrideForTesting, keychainFingerprintOverride: self.keychainFingerprintOverrideForTesting) @@ -132,12 +157,15 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current() let configuration = AttemptConfiguration( environment: environment, + profileIdentifier: profileIdentifier, interaction: interaction, readStrategy: readStrategy, // The delegated Claude process is an opaque Keychain boundary. Its policy must come // from the user's stored preference, not the strategy-adjusted mode used by our own reads. promptMode: ClaudeOAuthKeychainPromptPreference.storedMode(), - keychainAccessDisabled: KeychainAccessGate.isDisabled) + keychainAccessDisabled: KeychainAccessGate.isDisabled, + hasSelectedProfileOAuthCredentialsFile: ClaudeOAuthCredentialsStore + .hasSelectedProfileOAuthCredentialsFile(environment: environment)) #endif let task = Task.detached(priority: .utility) { #if DEBUG @@ -160,6 +188,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } state.inFlightAttemptID = attemptID state.inFlightInteraction = interaction + state.inFlightProfileIdentifier = profileIdentifier state.inFlightTask = task return .start(attemptID, task, state) } @@ -170,6 +199,8 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { configuration: AttemptConfiguration, state: AttemptStateStorage) async -> Outcome { + let profileIdentifier = configuration.profileIdentifier + // `/status` is an opaque Claude CLI invocation and may launch `/usr/bin/security` outside // CodexBar's own no-UI query controls. Background work may not cross that boundary unless // the user explicitly opted into always allowing Keychain access. @@ -190,6 +221,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { guard self.reserveAttemptIfNotInCooldown( now: now, bypassCooldown: configuration.interaction == .userInitiated, + profileIdentifier: profileIdentifier, state: state) else { self.log.debug("Claude OAuth delegated refresh skipped by cooldown") @@ -200,9 +232,14 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { interaction: configuration.interaction, readStrategy: configuration.readStrategy, keychainAccessDisabled: configuration.keychainAccessDisabled, + hasSelectedProfileOAuthCredentialsFile: configuration.hasSelectedProfileOAuthCredentialsFile, environment: configuration.environment) { - self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval, state: state) + self.recordAttempt( + now: now, + cooldown: self.defaultCooldownInterval, + profileIdentifier: profileIdentifier, + state: state) self.log.warning( "Claude OAuth delegated refresh skipped: Claude keychain has MCP OAuth state only", metadata: ["readStrategy": configuration.readStrategy.rawValue]) @@ -233,12 +270,20 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { configuration: configuration, timeout: min(max(timeout, 1), 2)) if changed { - self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval, state: state) + self.recordAttempt( + now: now, + cooldown: self.defaultCooldownInterval, + profileIdentifier: profileIdentifier, + state: state) self.log.info("Claude OAuth delegated refresh touch succeeded") return .attemptedSucceeded } - self.recordAttempt(now: now, cooldown: self.shortCooldownInterval, state: state) + self.recordAttempt( + now: now, + cooldown: self.shortCooldownInterval, + profileIdentifier: profileIdentifier, + state: state) if let touchError { let errorType = String(describing: type(of: touchError)) self.log.warning( @@ -252,23 +297,31 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { return .attemptedFailed("Claude keychain did not update after Claude CLI touch.") } - public static func isInCooldown(now: Date = Date()) -> Bool { + public static func isInCooldown( + now: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { let state = self.currentStateStorage + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) state.lock.lock() defer { state.lock.unlock() } - self.loadStateIfNeededLocked(state: state) - guard let lastAttemptAt = state.lastAttemptAt else { return false } - let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval + self.loadCooldownIfNeededLocked(profileIdentifier: profileIdentifier, state: state) + guard let lastAttemptAt = state.cooldownByProfile[profileIdentifier]?.lastAttemptAt else { return false } + let cooldown = state.cooldownByProfile[profileIdentifier]?.interval ?? self.defaultCooldownInterval return now.timeIntervalSince(lastAttemptAt) < cooldown } - public static func cooldownRemainingSeconds(now: Date = Date()) -> Int? { + public static func cooldownRemainingSeconds( + now: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment) -> Int? + { let state = self.currentStateStorage + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) state.lock.lock() defer { state.lock.unlock() } - self.loadStateIfNeededLocked(state: state) - guard let lastAttemptAt = state.lastAttemptAt else { return nil } - let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval + self.loadCooldownIfNeededLocked(profileIdentifier: profileIdentifier, state: state) + guard let lastAttemptAt = state.cooldownByProfile[profileIdentifier]?.lastAttemptAt else { return nil } + let cooldown = state.cooldownByProfile[profileIdentifier]?.interval ?? self.defaultCooldownInterval let remaining = cooldown - now.timeIntervalSince(lastAttemptAt) guard remaining > 0 else { return nil } return Int(remaining.rounded(.up)) @@ -439,9 +492,11 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { interaction: ProviderInteraction, readStrategy: ClaudeOAuthKeychainReadStrategy, keychainAccessDisabled: Bool, + hasSelectedProfileOAuthCredentialsFile: Bool, environment: [String: String]) -> String? { guard interaction != .userInitiated else { return nil } + guard !hasSelectedProfileOAuthCredentialsFile else { return nil } guard ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( interaction: interaction, readStrategy: readStrategy, @@ -459,69 +514,122 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { if state.inFlightAttemptID == id { state.inFlightAttemptID = nil state.inFlightInteraction = nil + state.inFlightProfileIdentifier = nil state.inFlightTask = nil } state.lock.unlock() } - private static func recordAttempt(now: Date, cooldown: TimeInterval, state: AttemptStateStorage) { + private static func recordAttempt( + now: Date, + cooldown: TimeInterval, + profileIdentifier: String, + state: AttemptStateStorage) + { state.lock.lock() defer { state.lock.unlock() } - self.loadStateIfNeededLocked(state: state) - state.lastAttemptAt = now - state.lastCooldownInterval = cooldown + self.loadCooldownIfNeededLocked(profileIdentifier: profileIdentifier, state: state) + state.cooldownByProfile[profileIdentifier] = CooldownState(lastAttemptAt: now, interval: cooldown) guard state.persistsCooldown else { return } - UserDefaults.standard.set(now.timeIntervalSince1970, forKey: self.cooldownDefaultsKey) - UserDefaults.standard.set(cooldown, forKey: self.cooldownIntervalDefaultsKey) + let defaults = UserDefaults.standard + defaults.set( + now.timeIntervalSince1970, + forKey: self.profileKey(self.cooldownDefaultsKey, profileIdentifier: profileIdentifier)) + defaults.set( + cooldown, + forKey: self.profileKey(self.cooldownIntervalDefaultsKey, profileIdentifier: profileIdentifier)) + self.removeLegacyCooldownIfDefaultProfile(profileIdentifier, defaults: defaults) } private static func reserveAttemptIfNotInCooldown( now: Date, bypassCooldown: Bool, + profileIdentifier: String, state: AttemptStateStorage) -> Bool { state.lock.lock() defer { state.lock.unlock() } - self.loadStateIfNeededLocked(state: state) + self.loadCooldownIfNeededLocked(profileIdentifier: profileIdentifier, state: state) - let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval + let profileState = state.cooldownByProfile[profileIdentifier] + let cooldown = profileState?.interval ?? self.defaultCooldownInterval if !bypassCooldown, - let lastAttemptAt = state.lastAttemptAt, + let lastAttemptAt = profileState?.lastAttemptAt, now.timeIntervalSince(lastAttemptAt) < cooldown { return false } // Reserve with a short cooldown; the final outcome will extend or keep it short. - state.lastAttemptAt = now - state.lastCooldownInterval = self.shortCooldownInterval + state.cooldownByProfile[profileIdentifier] = CooldownState( + lastAttemptAt: now, + interval: self.shortCooldownInterval) guard state.persistsCooldown else { return true } - UserDefaults.standard.set(now.timeIntervalSince1970, forKey: self.cooldownDefaultsKey) - UserDefaults.standard.set(self.shortCooldownInterval, forKey: self.cooldownIntervalDefaultsKey) + let defaults = UserDefaults.standard + defaults.set( + now.timeIntervalSince1970, + forKey: self.profileKey(self.cooldownDefaultsKey, profileIdentifier: profileIdentifier)) + defaults.set( + self.shortCooldownInterval, + forKey: self.profileKey(self.cooldownIntervalDefaultsKey, profileIdentifier: profileIdentifier)) + self.removeLegacyCooldownIfDefaultProfile(profileIdentifier, defaults: defaults) return true } - private static func loadStateIfNeededLocked(state: AttemptStateStorage) { - guard !state.hasLoadedState else { return } - state.hasLoadedState = true + private static func loadCooldownIfNeededLocked( + profileIdentifier: String, + state: AttemptStateStorage) + { + guard state.loadedCooldownProfiles.insert(profileIdentifier).inserted else { return } guard state.persistsCooldown else { - state.lastAttemptAt = nil - state.lastCooldownInterval = nil + state.cooldownByProfile[profileIdentifier] = CooldownState() return } - guard let raw = UserDefaults.standard.object(forKey: self.cooldownDefaultsKey) as? Double else { - state.lastAttemptAt = nil - state.lastCooldownInterval = nil + + let defaults = UserDefaults.standard + let timestampKey = self.profileKey(self.cooldownDefaultsKey, profileIdentifier: profileIdentifier) + let intervalKey = self.profileKey(self.cooldownIntervalDefaultsKey, profileIdentifier: profileIdentifier) + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + let shouldMigrateLegacy = profileIdentifier == defaultProfileIdentifier + && defaults.object(forKey: timestampKey) == nil + && defaults.object(forKey: self.cooldownDefaultsKey) != nil + let storedTimestampKey = shouldMigrateLegacy ? self.cooldownDefaultsKey : timestampKey + let storedIntervalKey = shouldMigrateLegacy ? self.cooldownIntervalDefaultsKey : intervalKey + + guard let raw = defaults.object(forKey: storedTimestampKey) as? Double else { + state.cooldownByProfile[profileIdentifier] = CooldownState() return } - state.lastAttemptAt = Date(timeIntervalSince1970: raw) - if let interval = UserDefaults.standard.object(forKey: self.cooldownIntervalDefaultsKey) as? Double { - state.lastCooldownInterval = interval - } else { - state.lastCooldownInterval = nil + + let cooldown = CooldownState( + lastAttemptAt: Date(timeIntervalSince1970: raw), + interval: defaults.object(forKey: storedIntervalKey) as? Double) + state.cooldownByProfile[profileIdentifier] = cooldown + if shouldMigrateLegacy { + defaults.set(raw, forKey: timestampKey) + if let interval = cooldown.interval { + defaults.set(interval, forKey: intervalKey) + } + self.removeLegacyCooldownIfDefaultProfile(profileIdentifier, defaults: defaults) } } + private static func profileKey(_ base: String, profileIdentifier: String) -> String { + base + self.cooldownProfileKeySeparator + profileIdentifier + } + + private static func removeLegacyCooldownIfDefaultProfile( + _ profileIdentifier: String, + defaults: UserDefaults) + { + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + guard profileIdentifier == defaultProfileIdentifier else { return } + defaults.removeObject(forKey: self.cooldownDefaultsKey) + defaults.removeObject(forKey: self.cooldownIntervalDefaultsKey) + } + #if DEBUG @TaskLocal private static var stateStorageForTesting: AttemptStateStorage? @TaskLocal static var cliAvailableOverrideForTesting: Bool? @@ -531,6 +639,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { @TaskLocal static var keychainFingerprintOverrideForTesting: (@Sendable () -> ClaudeOAuthCredentialsStore .ClaudeKeychainFingerprint?)? @TaskLocal static var userInitiatedBackgroundJoinObserverForTesting: (@Sendable () -> Void)? + @TaskLocal static var differentProfileJoinObserverForTesting: (@Sendable () -> Void)? static func withCLIAvailableOverrideForTesting( _ override: Bool?, @@ -568,6 +677,15 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } } + static func withDifferentProfileJoinObserverForTesting( + _ observer: (@Sendable () -> Void)?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$differentProfileJoinObserverForTesting.withValue(observer) { + try await operation() + } + } + static func withIsolatedStateForTesting(operation: () async throws -> T) async rethrows -> T { let state = AttemptStateStorage(persistsCooldown: false) return try await self.$stateStorageForTesting.withValue(state) { @@ -578,17 +696,24 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { static func resetForTesting() { let state = self.currentStateStorage state.lock.lock() - state.hasLoadedState = true - state.lastAttemptAt = nil - state.lastCooldownInterval = nil + state.loadedCooldownProfiles.removeAll() + state.cooldownByProfile.removeAll() state.inFlightAttemptID = nil state.inFlightInteraction = nil + state.inFlightProfileIdentifier = nil state.inFlightTask = nil state.nextAttemptID = 0 state.lock.unlock() guard state.persistsCooldown else { return } - UserDefaults.standard.removeObject(forKey: self.cooldownDefaultsKey) - UserDefaults.standard.removeObject(forKey: self.cooldownIntervalDefaultsKey) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: self.cooldownDefaultsKey) + defaults.removeObject(forKey: self.cooldownIntervalDefaultsKey) + for key in defaults.dictionaryRepresentation().keys + where key.hasPrefix(self.cooldownDefaultsKey + self.cooldownProfileKeySeparator) + || key.hasPrefix(self.cooldownIntervalDefaultsKey + self.cooldownProfileKeySeparator) + { + defaults.removeObject(forKey: key) + } } #endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift index 82e51867e7..5e75113a2c 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift @@ -157,6 +157,7 @@ public enum ClaudeOAuthKeychainPromptPreference { public static func withTaskOverrideForTesting( _ mode: ClaudeOAuthKeychainPromptMode?, + isolation _: isolated (any Actor)? = #isolation, operation: () async throws -> T) async rethrows -> T { try await self.$taskOverride.withValue(mode) { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift index 0017188f83..5c5e1d4781 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift @@ -10,8 +10,20 @@ public enum ClaudeOAuthRefreshFailureGate { } struct AuthFingerprint: Codable, Equatable { - let keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint? let credentialsFile: String? + + init(credentialsFile: String?) { + self.credentialsFile = credentialsFile + } + + #if DEBUG + init( + keychain _: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?, + credentialsFile: String?) + { + self.credentialsFile = credentialsFile + } + #endif } private struct State { @@ -25,7 +37,11 @@ public enum ClaudeOAuthRefreshFailureGate { var terminalReason: String? } - private static let lock = OSAllocatedUnfairLock(initialState: State()) + private struct LockedState { + var profiles: [String: State] = [:] + } + + private static let lock = OSAllocatedUnfairLock(initialState: LockedState()) private static let blockedUntilKey = "claudeOAuthRefreshBackoffBlockedUntilV1" // legacy (migration) private static let failureCountKey = "claudeOAuthRefreshBackoffFailureCountV1" // legacy + terminal count private static let fingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2" @@ -33,10 +49,11 @@ public enum ClaudeOAuthRefreshFailureGate { private static let terminalReasonKey = "claudeOAuthRefreshTerminalReasonV1" private static let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1" private static let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1" + private static let profileKeySeparator = ".profile." private static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static let minimumCredentialsRecheckInterval: TimeInterval = 15 - private static let unknownFingerprint = AuthFingerprint(keychain: nil, credentialsFile: nil) + private static let unknownFingerprint = AuthFingerprint(credentialsFile: nil) private static let transientBaseInterval: TimeInterval = 60 * 5 private static let transientMaxInterval: TimeInterval = 60 * 60 * 6 @@ -53,6 +70,17 @@ public enum ClaudeOAuthRefreshFailureGate { @TaskLocal private static var taskFingerprintProviderOverrideStore: FingerprintProviderOverrideStore? + final class EnvironmentFingerprintProviderOverrideStore: @unchecked Sendable { + let provider: ([String: String]) -> AuthFingerprint? + + init(provider: @escaping ([String: String]) -> AuthFingerprint?) { + self.provider = provider + } + } + + @TaskLocal private static var taskEnvironmentFingerprintProviderOverrideStore: + EnvironmentFingerprintProviderOverrideStore? + static func withFingerprintProviderOverrideForTesting( _ override: (() -> AuthFingerprint?)?, operation: () throws -> T) rethrows -> T @@ -64,6 +92,17 @@ public enum ClaudeOAuthRefreshFailureGate { } } + static func withEnvironmentFingerprintProviderOverrideForTesting( + _ override: (([String: String]) -> AuthFingerprint?)?, + operation: () throws -> T) rethrows -> T + { + try self.$taskEnvironmentFingerprintProviderOverrideStore.withValue( + override.map(EnvironmentFingerprintProviderOverrideStore.init(provider:))) + { + try operation() + } + } + static func withFingerprintProviderOverrideForTesting( _ override: (() -> AuthFingerprint?)?, isolation _: isolated (any Actor)? = #isolation, @@ -77,57 +116,54 @@ public enum ClaudeOAuthRefreshFailureGate { } public static func resetInMemoryStateForTesting() { - self.lock.withLock { state in - state.loaded = false - state.terminalFailureCount = 0 - state.transientFailureCount = 0 - state.isTerminalBlocked = false - state.transientBlockedUntil = nil - state.fingerprintAtFailure = nil - state.lastCredentialsRecheckAt = nil - state.terminalReason = nil + self.lock.withLock { lockedState in + lockedState.profiles.removeAll() } } public static func resetForTesting() { - self.lock.withLock { state in - state.loaded = false - state.terminalFailureCount = 0 - state.transientFailureCount = 0 - state.isTerminalBlocked = false - state.transientBlockedUntil = nil - state.fingerprintAtFailure = nil - state.lastCredentialsRecheckAt = nil - state.terminalReason = nil - UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) - UserDefaults.standard.removeObject(forKey: self.failureCountKey) - UserDefaults.standard.removeObject(forKey: self.fingerprintKey) - UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey) - UserDefaults.standard.removeObject(forKey: self.terminalReasonKey) - UserDefaults.standard.removeObject(forKey: self.transientBlockedUntilKey) - UserDefaults.standard.removeObject(forKey: self.transientFailureCountKey) + self.lock.withLock { lockedState in + lockedState.profiles.removeAll() + let defaults = UserDefaults.standard + for key in self.persistedKeys { + defaults.removeObject(forKey: key) + } + for key in defaults.dictionaryRepresentation().keys + where self.persistedKeys.contains(where: { key.hasPrefix($0 + self.profileKeySeparator) }) + { + defaults.removeObject(forKey: key) + } } } #endif - public static func shouldAttempt(now: Date = Date()) -> Bool { + public static func shouldAttempt( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) -> Bool + { #if DEBUG - if let override = self.shouldAttemptOverride { return override } + if let override = self.shouldAttemptOverride { + return override + } #endif - return self.lock.withLock { state in - let didMigrate = self.loadIfNeeded(&state, now: now) + return self.withState(environment: environment) { state, profileIdentifier in + let didMigrate = self.loadIfNeeded( + &state, + profileIdentifier: profileIdentifier, + environment: environment, + now: now) if didMigrate { - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) } if state.isTerminalBlocked { guard self.shouldRecheckCredentials(now: now, state: state) else { return false } state.lastCredentialsRecheckAt = now - if self.hasCredentialsChangedSinceFailure(state) { + if self.hasCredentialsChangedSinceFailure(state, environment: environment) { self.resetState(&state) - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) return true } @@ -147,15 +183,15 @@ public enum ClaudeOAuthRefreshFailureGate { // fingerprints and so we don't ratchet backoff across unrelated intermittent failures. state.fingerprintAtFailure = nil state.lastCredentialsRecheckAt = nil - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) return true } if self.shouldRecheckCredentials(now: now, state: state) { state.lastCredentialsRecheckAt = now - if self.hasCredentialsChangedSinceFailure(state) { + if self.hasCredentialsChangedSinceFailure(state, environment: environment) { self.resetState(&state) - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) return true } } @@ -173,9 +209,19 @@ public enum ClaudeOAuthRefreshFailureGate { } } - public static func currentBlockStatus(now: Date = Date()) -> BlockStatus? { - self.lock.withLock { state in - _ = self.loadIfNeeded(&state, now: now) + public static func currentBlockStatus( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) -> BlockStatus? + { + self.withState(environment: environment) { state, profileIdentifier in + if self.loadIfNeeded( + &state, + profileIdentifier: profileIdentifier, + environment: environment, + now: now) + { + self.persist(state, profileIdentifier: profileIdentifier) + } if state.isTerminalBlocked { return .terminal(reason: state.terminalReason, failures: state.terminalFailureCount) } @@ -186,22 +232,36 @@ public enum ClaudeOAuthRefreshFailureGate { } } - public static func recordTerminalAuthFailure(now: Date = Date()) { - self.lock.withLock { state in - _ = self.loadIfNeeded(&state, now: now) + public static func recordTerminalAuthFailure( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) + { + self.withState(environment: environment) { state, profileIdentifier in + _ = self.loadIfNeeded( + &state, + profileIdentifier: profileIdentifier, + environment: environment, + now: now) state.terminalFailureCount += 1 state.isTerminalBlocked = true state.terminalReason = "invalid_grant" - state.fingerprintAtFailure = self.currentFingerprint() ?? self.unknownFingerprint + state.fingerprintAtFailure = self.currentFingerprint(environment: environment) ?? self.unknownFingerprint state.lastCredentialsRecheckAt = now self.clearTransientState(&state) - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) } } - public static func recordTransientFailure(now: Date = Date()) { - self.lock.withLock { state in - _ = self.loadIfNeeded(&state, now: now) + public static func recordTransientFailure( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) + { + self.withState(environment: environment) { state, profileIdentifier in + _ = self.loadIfNeeded( + &state, + profileIdentifier: profileIdentifier, + environment: environment, + now: now) // Keep terminal blocking monotonic: once we know auth is rejected (e.g. invalid_grant), // do not downgrade it to time-based backoff unless auth changes (fingerprint) or we record success. @@ -212,22 +272,31 @@ public enum ClaudeOAuthRefreshFailureGate { state.transientFailureCount += 1 let interval = self.transientCooldownInterval(failures: state.transientFailureCount) state.transientBlockedUntil = now.addingTimeInterval(interval) - state.fingerprintAtFailure = self.currentFingerprint() ?? self.unknownFingerprint + state.fingerprintAtFailure = self.currentFingerprint(environment: environment) ?? self.unknownFingerprint state.lastCredentialsRecheckAt = now - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) } } - public static func recordAuthFailure(now: Date = Date()) { + public static func recordAuthFailure( + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) + { // Legacy shim: treat as terminal auth failure. - self.recordTerminalAuthFailure(now: now) + self.recordTerminalAuthFailure(environment: environment, now: now) } - public static func recordSuccess() { - self.lock.withLock { state in - _ = self.loadIfNeeded(&state, now: Date()) + public static func recordSuccess( + environment: [String: String] = ProcessInfo.processInfo.environment) + { + self.withState(environment: environment) { state, profileIdentifier in + _ = self.loadIfNeeded( + &state, + profileIdentifier: profileIdentifier, + environment: environment, + now: Date()) self.resetState(&state) - self.persist(state) + self.persist(state, profileIdentifier: profileIdentifier) } } @@ -236,57 +305,110 @@ public enum ClaudeOAuthRefreshFailureGate { return now.timeIntervalSince(last) >= self.minimumCredentialsRecheckInterval } - private static func hasCredentialsChangedSinceFailure(_ state: State) -> Bool { - guard let current = self.currentFingerprint() else { return false } + private static func hasCredentialsChangedSinceFailure( + _ state: State, + environment: [String: String]) -> Bool + { + guard let current = self.currentFingerprint(environment: environment) else { return false } guard let prior = state.fingerprintAtFailure else { return false } return current != prior } - private static func currentFingerprint() -> AuthFingerprint? { + private static func currentFingerprint(environment: [String: String]) -> AuthFingerprint? { #if DEBUG - if let override = self.taskFingerprintProviderOverrideStore { return override.provider() } + if let override = self.taskEnvironmentFingerprintProviderOverrideStore { + return override.provider(environment) + } + if let override = self.taskFingerprintProviderOverrideStore { + return override.provider() + } #endif return AuthFingerprint( - keychain: ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPromptForAuthGate(), - credentialsFile: ClaudeOAuthCredentialsStore.currentCredentialsFileFingerprintWithoutPromptForAuthGate()) + credentialsFile: ClaudeOAuthCredentialsStore.currentCredentialsFileFingerprintWithoutPromptForAuthGate( + environment: environment)) + } + + private static var persistedKeys: [String] { + [ + self.blockedUntilKey, + self.failureCountKey, + self.fingerprintKey, + self.terminalBlockedKey, + self.terminalReasonKey, + self.transientBlockedUntilKey, + self.transientFailureCountKey, + ] + } + + private static func withState( + environment: [String: String], + operation: @Sendable (inout State, String) -> T) -> T + { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + return self.lock.withLock { lockedState in + var state = lockedState.profiles[profileIdentifier] ?? State() + let result = operation(&state, profileIdentifier) + lockedState.profiles[profileIdentifier] = state + return result + } + } + + private static func profileKey(_ base: String, profileIdentifier: String) -> String { + base + self.profileKeySeparator + profileIdentifier } - private static func loadIfNeeded(_ state: inout State, now: Date) -> Bool { + private static func loadIfNeeded( + _ state: inout State, + profileIdentifier: String, + environment _: [String: String], + now: Date) -> Bool + { state.loaded = true var didMutate = false + let defaults = UserDefaults.standard + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + let scopedKey: (String) -> String = { self.profileKey($0, profileIdentifier: profileIdentifier) } + let hasScopedState = self.persistedKeys.contains { defaults.object(forKey: scopedKey($0)) != nil } + let hasLegacyState = self.persistedKeys.contains { defaults.object(forKey: $0) != nil } + let shouldMigrateLegacy = !hasScopedState && profileIdentifier == defaultProfileIdentifier && hasLegacyState + let storageKey: (String) -> String = shouldMigrateLegacy ? { $0 } : scopedKey // Always refresh persisted fields from UserDefaults, even after first load. // // This avoids stale state when UserDefaults are modified while the app is running (or during tests), // while still keeping ephemeral throttling state (like lastCredentialsRecheckAt) in memory. - state.terminalFailureCount = UserDefaults.standard.integer(forKey: self.failureCountKey) - state.transientFailureCount = UserDefaults.standard.integer(forKey: self.transientFailureCountKey) + state.terminalFailureCount = defaults.integer(forKey: storageKey(self.failureCountKey)) + state.transientFailureCount = defaults.integer(forKey: storageKey(self.transientFailureCountKey)) + state.isTerminalBlocked = false + state.terminalReason = nil + state.transientBlockedUntil = nil + state.fingerprintAtFailure = nil - if let raw = UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) as? Double { + if let raw = defaults.object(forKey: storageKey(self.transientBlockedUntilKey)) as? Double { state.transientBlockedUntil = Date(timeIntervalSince1970: raw) } - let legacyBlockedUntil = (UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double) - .map { Date(timeIntervalSince1970: $0) } - let legacyFailureCount = UserDefaults.standard.integer(forKey: self.failureCountKey) + let legacyBlockedUntil = shouldMigrateLegacy + ? (defaults.object(forKey: self.blockedUntilKey) as? Double).map { Date(timeIntervalSince1970: $0) } + : nil + let legacyFailureCount = shouldMigrateLegacy ? defaults.integer(forKey: self.failureCountKey) : 0 - if let data = UserDefaults.standard.data(forKey: self.fingerprintKey) { + if let data = defaults.data(forKey: storageKey(self.fingerprintKey)) { state.fingerprintAtFailure = (try? JSONDecoder().decode(AuthFingerprint.self, from: data)) - } else { - state.fingerprintAtFailure = nil } - if UserDefaults.standard.object(forKey: self.terminalBlockedKey) != nil { - state.isTerminalBlocked = UserDefaults.standard.bool(forKey: self.terminalBlockedKey) - state.terminalReason = UserDefaults.standard.string(forKey: self.terminalReasonKey) + if defaults.object(forKey: storageKey(self.terminalBlockedKey)) != nil { + state.isTerminalBlocked = defaults.bool(forKey: storageKey(self.terminalBlockedKey)) + state.terminalReason = defaults.string(forKey: storageKey(self.terminalReasonKey)) if legacyBlockedUntil != nil { didMutate = true } } else { // Migration: legacy keys represented a time-based backoff. Migrate to transient backoff (never terminal) // unless we already have new transient keys persisted. - if UserDefaults.standard.object(forKey: self.transientFailureCountKey) == nil, - UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil, + if defaults.object(forKey: storageKey(self.transientFailureCountKey)) == nil, + defaults.object(forKey: storageKey(self.transientBlockedUntilKey)) == nil, legacyBlockedUntil != nil || legacyFailureCount > 0 { state.isTerminalBlocked = false @@ -313,33 +435,47 @@ public enum ClaudeOAuthRefreshFailureGate { didMutate = true } + if shouldMigrateLegacy { + didMutate = true + } + return didMutate } - private static func persist(_ state: State) { - UserDefaults.standard.set(state.terminalFailureCount, forKey: self.failureCountKey) - UserDefaults.standard.set(state.isTerminalBlocked, forKey: self.terminalBlockedKey) + private static func persist(_ state: State, profileIdentifier: String) { + let defaults = UserDefaults.standard + let key: (String) -> String = { self.profileKey($0, profileIdentifier: profileIdentifier) } + defaults.set(state.terminalFailureCount, forKey: key(self.failureCountKey)) + defaults.set(state.isTerminalBlocked, forKey: key(self.terminalBlockedKey)) if let reason = state.terminalReason { - UserDefaults.standard.set(reason, forKey: self.terminalReasonKey) + defaults.set(reason, forKey: key(self.terminalReasonKey)) } else { - UserDefaults.standard.removeObject(forKey: self.terminalReasonKey) + defaults.removeObject(forKey: key(self.terminalReasonKey)) } - UserDefaults.standard.set(state.transientFailureCount, forKey: self.transientFailureCountKey) + defaults.set(state.transientFailureCount, forKey: key(self.transientFailureCountKey)) if let blockedUntil = state.transientBlockedUntil { - UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.transientBlockedUntilKey) + defaults.set(blockedUntil.timeIntervalSince1970, forKey: key(self.transientBlockedUntilKey)) } else { - UserDefaults.standard.removeObject(forKey: self.transientBlockedUntilKey) + defaults.removeObject(forKey: key(self.transientBlockedUntilKey)) } - UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + defaults.removeObject(forKey: key(self.blockedUntilKey)) if let fingerprint = state.fingerprintAtFailure, let data = try? JSONEncoder().encode(fingerprint) { - UserDefaults.standard.set(data, forKey: self.fingerprintKey) + defaults.set(data, forKey: key(self.fingerprintKey)) } else { - UserDefaults.standard.removeObject(forKey: self.fingerprintKey) + defaults.removeObject(forKey: key(self.fingerprintKey)) + } + + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment) + if profileIdentifier == defaultProfileIdentifier { + for legacyKey in self.persistedKeys { + defaults.removeObject(forKey: legacyKey) + } } } @@ -374,21 +510,34 @@ public enum ClaudeOAuthRefreshFailureGate { case transient(until: Date, failures: Int) } - public static func shouldAttempt(now _: Date = Date()) -> Bool { + public static func shouldAttempt( + environment _: [String: String] = ProcessInfo.processInfo.environment, + now _: Date = Date()) -> Bool + { true } - public static func currentBlockStatus(now _: Date = Date()) -> BlockStatus? { + public static func currentBlockStatus( + environment _: [String: String] = ProcessInfo.processInfo.environment, + now _: Date = Date()) -> BlockStatus? + { nil } - public static func recordTerminalAuthFailure(now _: Date = Date()) {} + public static func recordTerminalAuthFailure( + environment _: [String: String] = ProcessInfo.processInfo.environment, + now _: Date = Date()) {} - public static func recordTransientFailure(now _: Date = Date()) {} + public static func recordTransientFailure( + environment _: [String: String] = ProcessInfo.processInfo.environment, + now _: Date = Date()) {} - public static func recordAuthFailure(now _: Date = Date()) {} + public static func recordAuthFailure( + environment _: [String: String] = ProcessInfo.processInfo.environment, + now _: Date = Date()) {} - public static func recordSuccess() {} + public static func recordSuccess( + environment _: [String: String] = ProcessInfo.processInfo.environment) {} #if DEBUG static func withFingerprintProviderOverrideForTesting( @@ -398,6 +547,13 @@ public enum ClaudeOAuthRefreshFailureGate { try operation() } + static func withEnvironmentFingerprintProviderOverrideForTesting( + _ override: (([String: String]) -> Any?)?, + operation: () throws -> T) rethrows -> T + { + try operation() + } + static func withFingerprintProviderOverrideForTesting( _ override: (() -> Any?)?, isolation _: isolated (any Actor)? = #isolation, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index bf713f0e0b..9f4e1801a7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -18,6 +18,22 @@ public enum ClaudeOAuthFetchError: LocalizedError, Sendable { description == self.usageRateLimitDescription } + static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { + return true + } + if let urlError = error as? URLError, urlError.code == .cancelled { + return true + } + if let fetchError = error as? ClaudeOAuthFetchError, + case let .networkError(underlying) = fetchError + { + return self.isCancellation(underlying) + } + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled + } + public var errorDescription: String? { switch self { case .unauthorized: @@ -98,6 +114,8 @@ enum ClaudeOAuthUsageFetcher { let body = String(data: data, encoding: .utf8) throw ClaudeOAuthFetchError.serverError(response.statusCode, body) } + } catch let error where ClaudeOAuthFetchError.isCancellation(error) { + throw error } catch let error as ClaudeOAuthFetchError { throw error } catch { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift index fa7cce370d..7747268059 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProbeSessionArtifactCleaner.swift @@ -11,39 +11,35 @@ enum ClaudeProbeSessionArtifactCleaner { fileManager fm: FileManager = .default) -> [URL] { let projectDirectoryName = self.claudeProjectDirectoryName(for: probeDirectory) - var visitedDirectories = Set() - var removedFiles: [URL] = [] - - for root in self.claudeConfigRoots(environment: environment, fileManager: fm) { - let projectsRoot = root.appendingPathComponent("projects", isDirectory: true) - let directories = [projectsRoot.appendingPathComponent(projectDirectoryName, isDirectory: true)] - - for directory in directories where visitedDirectories.insert(directory.path).inserted { - guard let entries = try? fm.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles]) - else { continue } + let profileRoot = ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: probeDirectory) + let directory = profileRoot + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(projectDirectoryName, isDirectory: true) + guard let entries = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } - for entry in entries where entry.pathExtension == "jsonl" { - let values = try? entry.resourceValues(forKeys: [.isRegularFileKey]) - guard values?.isRegularFile == true else { continue } - do { - try fm.removeItem(at: entry) - removedFiles.append(entry) - } catch { - Self.log.debug( - "Claude probe session artifact cleanup skipped file", - metadata: ["error": error.localizedDescription]) - } - } - - if (try? fm.contentsOfDirectory(atPath: directory.path).isEmpty) == true { - try? fm.removeItem(at: directory) - } + var removedFiles: [URL] = [] + for entry in entries where entry.pathExtension == "jsonl" { + let values = try? entry.resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + do { + try fm.removeItem(at: entry) + removedFiles.append(entry) + } catch { + Self.log.debug( + "Claude probe session artifact cleanup skipped file", + metadata: ["error": error.localizedDescription]) } } + if (try? fm.contentsOfDirectory(atPath: directory.path).isEmpty) == true { + try? fm.removeItem(at: directory) + } return removedFiles } @@ -71,35 +67,4 @@ enum ClaudeProbeSessionArtifactCleaner { let magnitude = hash < 0 ? -Int64(hash) : Int64(hash) return String(magnitude, radix: 36) } - - private static func claudeConfigRoots( - environment: [String: String], - fileManager fm: FileManager) -> [URL] - { - var roots: [URL] = [] - var seen = Set() - - func append(_ url: URL) { - let standardized = url.standardizedFileURL - guard seen.insert(standardized.path).inserted else { return } - roots.append(standardized) - } - - if let raw = environment["CLAUDE_CONFIG_DIR"] { - for part in raw.split(separator: ",") { - let path = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty else { continue } - append(URL(fileURLWithPath: path)) - } - } - - let home = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() - append(URL(fileURLWithPath: home).appendingPathComponent(".claude", isDirectory: true)) - append(URL(fileURLWithPath: home).appendingPathComponent(".config/claude", isDirectory: true)) - - if roots.isEmpty { - append(fm.homeDirectoryForCurrentUser.appendingPathComponent(".claude", isDirectory: true)) - } - return roots - } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 7943471cb5..fce776e1f4 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -48,10 +48,31 @@ public enum ClaudeProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - if context.sourceMode == .api || self.hasAutoAdminAPIKey(context: context) { - return [ClaudeAdminAPIFetchStrategy()] + // An explicitly selected account is the credential authority for this fetch. The global + // source only controls ambient-account routing and must not redirect a selected account. + if context.selectedTokenAccountID != nil { + if ClaudeAdminAPIFetchStrategy.isSelectedAdminAPIAccount(context: context) { + return [ClaudeAdminAPIFetchStrategy()] + } + if self.isSelectedOAuthTokenAccount(context: context) { + return [ClaudeOAuthFetchStrategy()] + } + if self.isSelectedWebCookieTokenAccount(context: context) { + return [ClaudeWebFetchStrategy(browserDetection: context.browserDetection)] + } + // A selected account is an authority boundary. Missing or malformed selected credentials must never + // fall through to an ambient CLI, browser session, or global API key and be labeled as that account. + return [] } - if ClaudeAdminAPIFetchStrategy.isSelectedAdminAPIAccount(context: context) { + if context.claudeOwnerCLIRecoveryOnly { + return [ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: nil, + browserDetection: context.browserDetection, + hasWebFallback: false)] + } + if context.sourceMode == .api || self.hasAutoAdminAPIKey(context: context) { return [ClaudeAdminAPIFetchStrategy()] } @@ -89,10 +110,32 @@ public enum ClaudeProviderDescriptor { context.sourceMode == .auto && ClaudeAdminAPISettingsReader.apiKey(environment: context.env) != nil } + private static func isSelectedOAuthTokenAccount(context: ProviderFetchContext) -> Bool { + guard context.selectedTokenAccountID != nil, + context.settings?.claude?.usageDataSource == .oauth + else { + return false + } + return !(context.env[ClaudeOAuthCredentialsStore.environmentTokenKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true) + } + + private static func isSelectedWebCookieTokenAccount(context: ProviderFetchContext) -> Bool { + guard context.selectedTokenAccountID != nil, + context.settings?.claude?.usageDataSource == .web, + context.settings?.claude?.cookieSource == .manual + else { + return false + } + return CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) != nil + } + private static func makePlanningInput(context: ProviderFetchContext) -> ClaudeSourcePlanningInput { let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false - let needsOAuthAvailability = context.runtime == .app && context.sourceMode == .auto let hasWebSession = Self.hasPlausibleWebSession(context: context) + let shouldAttemptOAuth = context.runtime == .app && + (context.sourceMode == .auto || context.sourceMode == .oauth) return ClaudeSourcePlanningInput( runtime: context.runtime, @@ -100,10 +143,9 @@ public enum ClaudeProviderDescriptor { webExtrasEnabled: webExtrasEnabled, hasWebSession: hasWebSession, hasCLI: ClaudeCLIResolver.isAvailable(environment: context.env), - hasOAuthCredentials: needsOAuthAvailability && ClaudeOAuthPlanningAvailability.isAvailable( - runtime: context.runtime, - sourceMode: context.sourceMode, - environment: context.env)) + // App Auto and explicit OAuth perform one real, noninteractive OAuth attempt. Do not preflight here: + // a preflight can mutate cache state and make the real fetch misclassify a typed error. + hasOAuthCredentials: shouldAttemptOAuth) } private static func hasPlausibleWebSession(context: ProviderFetchContext) -> Bool { @@ -208,7 +250,10 @@ public enum ClaudeOAuthPlanningAvailability { sourceMode: ProviderSourceMode, environment: [String: String]) -> Bool { - ClaudeOAuthFetchStrategy.isPlausiblyAvailable( + if runtime == .app, sourceMode == .oauth { + return !ClaudeOAuthFetchStrategy().directCredentialIsMissing(environment: environment) + } + return ClaudeOAuthFetchStrategy.isPlausiblyAvailable( runtime: runtime, sourceMode: sourceMode, environment: environment) @@ -228,6 +273,12 @@ private struct ClaudePlannedFetchStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { + if context.runtime == .app, + context.sourceMode == .oauth, + self.plannedStep.dataSource == .oauth + { + return true + } guard context.sourceMode == .auto else { return await self.base.isAvailable(context) } @@ -315,6 +366,30 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { allowClaudeKeychainRepairWithoutPrompt: false) } + func directCredentialIsMissing(environment: [String: String]) -> Bool { + #if DEBUG + if Self.nonInteractiveCredentialRecordOverride != nil { + return false + } + #endif + + do { + _ = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false, + clearInvalidCache: false) + return false + } catch ClaudeOAuthCredentialsError.notFound { + return true + } catch { + // A malformed, unreadable, or otherwise unusable direct credential remains an explicit + // OAuth error. Do not hide it by silently switching credential authorities. + return false + } + } + private func isClaudeCLIAvailable(environment: [String: String]) -> Bool { #if DEBUG if let override = Self.claudeCLIAvailableOverride { @@ -336,6 +411,11 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { return true } + // Explicit OAuth is authoritative and must execute exactly once so its concrete credential + // result is preserved. In particular, do not destructively probe a malformed cache here and + // then misclassify the now-cleared cache as absent during fetch. + guard sourceMode == .auto else { return true } + let strategy = ClaudeOAuthFetchStrategy() let nonInteractiveRecord = strategy.loadNonInteractiveCredentialRecord(environment: environment) let nonInteractiveCredentials = nonInteractiveRecord?.credentials @@ -373,8 +453,6 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { } } - guard sourceMode == .auto else { return true } - let promptPolicyApplicable = ClaudeOAuthKeychainPromptPreference.isApplicable() if ProviderInteractionContext.current == .userInitiated { _ = ClaudeOAuthKeychainAccessGate.clearDenied() @@ -389,7 +467,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { } private static func hasMcpOAuthOnlyClaudeKeychainPayload(environment: [String: String]) -> Bool { - ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + ClaudeOAuthCredentialsStore.shouldBlockSelectedProfileForMcpOnlyClaudeKeychain( interaction: ProviderInteractionContext.current, environment: environment) } @@ -415,6 +493,8 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { runtime: context.runtime, dataSource: .oauth, oauthKeychainPromptCooldownEnabled: context.sourceMode == .auto, + oauthSafeCredentialSourcesOnly: context.sourceMode == .auto, + preserveInvalidOAuthCache: context.sourceMode == .oauth, allowBackgroundDelegatedRefresh: false, useWebExtras: useWebExtras, manualCookieHeader: webEnrichmentAccess.manualCookieHeader, @@ -431,15 +511,30 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { strategyKind: self.kind, claudeOAuthKeychainPersistentRefHash: usage.oauthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: usage.oauthHistoryOwnerIdentifier, + claudeOAuthCredentialOwner: usage.oauthCredentialOwner, claudeOAuthKeychainCredentialMismatch: usage.oauthKeychainCredentialMismatch, claudeOAuthKeychainCredentialAbsent: usage.oauthKeychainCredentialAbsent, claudeOAuthKeychainCredentialUnavailable: usage.oauthKeychainCredentialUnavailable) } - func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { - // In Auto mode, fall back to the next strategy (cli/web) if OAuth fails (e.g. user cancels keychain prompt - // or auth breaks). - context.runtime == .app && context.sourceMode == .auto + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard !Task.isCancelled, !ClaudeOAuthFetchError.isCancellation(error) else { + return false + } + if context.runtime == .app, + context.sourceMode == .oauth, + let credentialsError = error as? ClaudeOAuthCredentialsError + { + switch credentialsError { + case .notFound, .refreshDelegatedToClaudeCLI: + return true + default: + break + } + } + // In Auto mode, fall back to the next strategy (cli/web) when safe credentials are absent or OAuth fails. + // Cancellation itself is always terminal. + return context.runtime == .app && context.sourceMode == .auto } fileprivate static func snapshot(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { @@ -681,15 +776,24 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { let hasWebFallback: Bool func isAvailable(_ context: ProviderFetchContext) async -> Bool { - let isBackgroundAutoRefresh = context.runtime == .app - && context.sourceMode == .auto + // Claude's "auth status" command is an opaque child process that may invoke /usr/bin/security itself. + // CodexBar cannot impose its no-UI policy on that child, so background Auto refresh must not launch it + // unless the user explicitly opted into Keychain access for background work. + let isBackgroundAppRefresh = context.runtime == .app && ProviderInteractionContext.current == .background + // Explicit OAuth may recover through the interactive owner CLI only from a user action. A scheduled + // refresh with missing credentials must remain on the selected OAuth authority and fail without UI. + if isBackgroundAppRefresh, context.sourceMode == .oauth { + return false + } + + let isBackgroundAutoRefresh = isBackgroundAppRefresh && context.sourceMode == .auto if isBackgroundAutoRefresh { // Every Claude child process is opaque to CodexBar's no-UI Keychain controls, including // `claude auth status`. Background Auto therefore reuses only availability established by a // successful user-initiated CLI fetch in this process; it never probes the CLI itself. guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env), - ClaudeCLIBackgroundAvailability.isEstablished(binary: binary) + ClaudeCLIBackgroundAvailability.isEstablished(binary: binary, environment: context.env) else { return false } @@ -702,8 +806,8 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { // The interactive Claude REPL can open browser OAuth when it starts logged out. CLI-runtime paths // establish authentication through the noninteractive status command first. App user // actions intentionally launch the interactive path directly so the user can complete authentication. - guard context.runtime == .cli else { return true } guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false } + guard context.runtime == .cli else { return true } return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env) } @@ -720,20 +824,23 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { includePrepaidBalance: self.includePrepaidBalance && context.includeOptionalUsage, keepCLISessionsAlive: keepAlive) let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) + let backgroundAvailabilityMarker = binary.flatMap { + ClaudeCLIBackgroundAvailability.captureMarker(binary: $0, environment: context.env) + } let usage: ClaudeUsageSnapshot do { usage = try await fetcher.loadLatestUsage(model: "sonnet") } catch { - if let binary { - ClaudeCLIBackgroundAvailability.revoke(binary: binary) + if let backgroundAvailabilityMarker { + ClaudeCLIBackgroundAvailability.revoke(backgroundAvailabilityMarker) } throw error } if context.runtime == .app, ProviderInteractionContext.current == .userInitiated, - let binary + let backgroundAvailabilityMarker { - ClaudeCLIBackgroundAvailability.establish(binary: binary) + ClaudeCLIBackgroundAvailability.establish(backgroundAvailabilityMarker) } return self.makeResult( usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), @@ -751,20 +858,25 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { } enum ClaudeCLIBackgroundAvailability { + struct Marker: Hashable { + let binary: String + let accountScope: String + } + final class Store: @unchecked Sendable { private let lock = NSLock() - private var establishedBinaries: Set = [] + private var markers: Set = [] - func contains(_ binary: String) -> Bool { - self.lock.withLock { self.establishedBinaries.contains(binary) } + func contains(_ marker: Marker) -> Bool { + self.lock.withLock { self.markers.contains(marker) } } - func insert(_ binary: String) { - self.lock.withLock { _ = self.establishedBinaries.insert(binary) } + func insert(_ marker: Marker) { + self.lock.withLock { _ = self.markers.insert(marker) } } - func remove(_ binary: String) { - self.lock.withLock { _ = self.establishedBinaries.remove(binary) } + func remove(_ marker: Marker) { + self.lock.withLock { _ = self.markers.remove(marker) } } } @@ -775,16 +887,29 @@ enum ClaudeCLIBackgroundAvailability { self.storeOverrideForTesting ?? self.sharedStore } - static func isEstablished(binary: String) -> Bool { - self.store.contains(binary) + static func isEstablished(binary: String, environment: [String: String]) -> Bool { + guard let marker = self.captureMarker(binary: binary, environment: environment) else { return false } + return self.store.contains(marker) + } + + static func establish(binary: String, environment: [String: String]) { + guard let marker = self.captureMarker(binary: binary, environment: environment) else { return } + self.establish(marker) } - static func establish(binary: String) { - self.store.insert(binary) + static func establish(_ marker: Marker) { + self.store.insert(marker) } - static func revoke(binary: String) { - self.store.remove(binary) + static func revoke(_ marker: Marker) { + self.store.remove(marker) + } + + static func captureMarker(binary: String, environment: [String: String]) -> Marker? { + guard let accountScope = ClaudeAccountProfile.identifiedSessionScope(environment: environment) else { + return nil + } + return Marker(binary: binary, accountScope: accountScope) } #if DEBUG diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift index 24ae2c0c12..ec65a64fba 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift @@ -27,6 +27,7 @@ public struct ClaudeSourcePlanningInput: Equatable, Sendable { public enum ClaudeSourcePlanReason: String, Equatable, Sendable { case explicitSourceSelection = "explicit-source-selection" + case explicitOAuthOwnerCLIFallback = "explicit-oauth-owner-cli-fallback" case appAutoPreferredOAuth = "app-auto-preferred-oauth" case appAutoFallbackCLI = "app-auto-fallback-cli" case appAutoFallbackWeb = "app-auto-fallback-web" @@ -71,6 +72,8 @@ public struct ClaudeFetchPlan: Equatable, Sendable { switch self.input.selectedDataSource { case .auto: self.availableSteps.first + case .oauth where self.input.runtime == .app: + self.availableSteps.first case .api, .oauth, .web, .cli: self.orderedSteps.first } @@ -187,7 +190,15 @@ public enum ClaudeSourcePlanner { case .api: [self.step(.api, reason: .explicitSourceSelection, input: input)] case .oauth: - [self.step(.oauth, reason: .explicitSourceSelection, input: input)] + switch input.runtime { + case .app: + [ + self.step(.oauth, reason: .explicitSourceSelection, input: input), + self.step(.cli, reason: .explicitOAuthOwnerCLIFallback, input: input), + ] + case .cli: + [self.step(.oauth, reason: .explicitSourceSelection, input: input)] + } case .web: [self.step(.web, reason: .explicitSourceSelection, input: input)] case .cli: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 1688444af5..50a9daa6fd 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -81,16 +81,26 @@ public struct ClaudeStatusProbe: Sendable { public var claudeBinary: String = "claude" public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false + public var environment: [String: String] = ProcessInfo.processInfo.environment + // Claude's interactive process binds account state at launch. Cross-refresh reuse is permitted only because the + // session actor also requires the hashed config-root + active-account scope to match. + static let accountScopedSessionReuseEnabled = true private static let log = CodexBarLog.logger(LogCategories.claudeProbe) #if DEBUG public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot @TaskLocal static var fetchOverride: FetchOverride? #endif - public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) { + public init( + claudeBinary: String = "claude", + timeout: TimeInterval = 20.0, + keepCLISessionsAlive: Bool = false, + environment: [String: String] = ProcessInfo.processInfo.environment) + { self.claudeBinary = claudeBinary self.timeout = timeout self.keepCLISessionsAlive = keepCLISessionsAlive + self.environment = environment } #if DEBUG @@ -118,29 +128,45 @@ public struct ClaudeStatusProbe: Sendable { #endif public func fetch() async throws -> ClaudeStatusSnapshot { - let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary) + let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary, environment: self.environment) guard let resolved, Self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } // Run commands sequentially through a shared Claude session to avoid warm-up churn. let timeout = self.timeout - let keepAlive = self.keepCLISessionsAlive + let keepAlive = Self.shouldKeepCLISessionAlive(requested: self.keepCLISessionsAlive) + let accountScope = ClaudeAccountProfile.sessionScope(environment: self.environment) #if DEBUG if let override = Self.fetchOverride { return try await override(resolved, timeout, keepAlive) } #endif do { - var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) + var usage = try await Self.capture( + subcommand: "/usage", + binary: resolved, + accountScope: accountScope, + timeout: timeout, + environment: self.environment) if !Self.usageOutputLooksRelevant(usage) { Self.log.debug("Claude CLI /usage looked like startup output; retrying once") - usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14)) + usage = try await Self.capture( + subcommand: "/usage", + binary: resolved, + accountScope: accountScope, + timeout: max(timeout, 14), + environment: self.environment) } // `/status` only enriches a valid usage snapshot with identity. Terminal usage errors and loading stalls // cannot be repaired by it, so fail now instead of paying for another interactive CLI round trip. try Self.validateUsageBeforeStatusProbe(usage) - let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) + let status = try? await Self.capture( + subcommand: "/status", + binary: resolved, + accountScope: accountScope, + timeout: min(timeout, 12), + environment: self.environment) let snap = try Self.parse(text: usage, statusText: status) Self.log.info("Claude CLI scrape ok", metadata: [ @@ -149,23 +175,27 @@ public struct ClaudeStatusProbe: Sendable { "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { - await Self.resetTransientCLISessionAndCleanupProbeArtifacts() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts(environment: self.environment) } return snap } catch { if !keepAlive { - await Self.resetTransientCLISessionAndCleanupProbeArtifacts() + await Self.resetTransientCLISessionAndCleanupProbeArtifacts(environment: self.environment) } throw error } } - private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { + private static func resetTransientCLISessionAndCleanupProbeArtifacts(environment: [String: String]) async { await ClaudeCLISession.current.reset() - let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts(environment: environment) guard !removed.isEmpty else { return } Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) } + + static func shouldKeepCLISessionAlive(requested: Bool) -> Bool { + requested && self.accountScopedSessionReuseEnabled + } } extension ClaudeStatusProbe { @@ -302,7 +332,12 @@ extension ClaudeStatusProbe { guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } - let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) + let statusText = try await Self.capture( + subcommand: "/status", + binary: resolved, + accountScope: ClaudeAccountProfile.sessionScope(environment: environment), + timeout: timeout, + environment: environment) return Self.parseIdentity(usageText: nil, statusText: statusText) } @@ -318,17 +353,19 @@ extension ClaudeStatusProbe { // Use a more robust capture configuration than the standard `/status` scrape: // - Avoid the short idle-timeout which can terminate the session while CLI auth checks are still running. // - We intentionally do not parse output here; success is "the command ran without timing out". - _ = try await ClaudeCLISession.shared.capture( + _ = try await ClaudeCLISession.current.capture( subcommand: "/status", binary: resolved, + accountScope: ClaudeAccountProfile.sessionScope(environment: environment), timeout: timeout, + environment: environment, idleTimeout: nil, stopOnSubstrings: [], settleAfterStop: 0.8, sendEnterEvery: 0.8) - await ClaudeCLISession.shared.reset() + await ClaudeCLISession.current.reset() } catch { - await ClaudeCLISession.shared.reset() + await ClaudeCLISession.current.reset() throw error } } @@ -1423,7 +1460,13 @@ extension ClaudeStatusProbe { } /// Run claude CLI inside a PTY so we can respond to interactive permission prompts. - private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { + private static func capture( + subcommand: String, + binary: String, + accountScope: String, + timeout: TimeInterval, + environment: [String: String]) async throws -> String + { let stopOnSubstrings = subcommand == "/usage" ? [ "Failed to load usage data", @@ -1444,7 +1487,9 @@ extension ClaudeStatusProbe { return try await ClaudeCLISession.current.capture( subcommand: subcommand, binary: binary, + accountScope: accountScope, timeout: timeout, + environment: environment, idleTimeout: idleTimeout, stopOnSubstrings: stopOnSubstrings, stopWhenNormalized: stopWhenNormalized, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index dc3f67fdf5..cc053fe4a6 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -27,6 +27,8 @@ public struct ClaudeUsageSnapshot: Sendable { public let oauthKeychainPersistentRefHash: String? /// One-way, high-entropy ownership evidence derived from the exact credential used for this OAuth fetch. public let oauthHistoryOwnerIdentifier: String? + /// The authority that owned the credential used for this OAuth fetch. + public let oauthCredentialOwner: ClaudeOAuthCredentialOwner? /// True when a prompt-free comparison proved this credential differs from Claude Code's Keychain entry. public let oauthKeychainCredentialMismatch: Bool /// True when a prompt-free probe proved Claude Code has no Keychain credential. @@ -48,6 +50,7 @@ public struct ClaudeUsageSnapshot: Sendable { rawText: String?, oauthKeychainPersistentRefHash: String? = nil, oauthHistoryOwnerIdentifier: String? = nil, + oauthCredentialOwner: ClaudeOAuthCredentialOwner? = nil, oauthKeychainCredentialMismatch: Bool = false, oauthKeychainCredentialAbsent: Bool = false, oauthKeychainCredentialUnavailable: Bool = false) @@ -65,6 +68,7 @@ public struct ClaudeUsageSnapshot: Sendable { self.rawText = rawText self.oauthKeychainPersistentRefHash = oauthKeychainPersistentRefHash self.oauthHistoryOwnerIdentifier = oauthHistoryOwnerIdentifier + self.oauthCredentialOwner = oauthCredentialOwner self.oauthKeychainCredentialMismatch = oauthKeychainCredentialMismatch self.oauthKeychainCredentialAbsent = oauthKeychainCredentialAbsent self.oauthKeychainCredentialUnavailable = oauthKeychainCredentialUnavailable @@ -109,6 +113,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let runtime: ProviderRuntime let dataSource: ClaudeUsageDataSource let oauthKeychainPromptCooldownEnabled: Bool + let oauthSafeCredentialSourcesOnly: Bool + let preserveInvalidOAuthCache: Bool let allowBackgroundDelegatedRefresh: Bool let useWebExtras: Bool let manualCookieHeader: String? @@ -141,6 +147,14 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { self.configuration.oauthKeychainPromptCooldownEnabled } + private var oauthSafeCredentialSourcesOnly: Bool { + self.dataSource == .auto || self.configuration.oauthSafeCredentialSourcesOnly + } + + private var preserveInvalidOAuthCache: Bool { + self.configuration.preserveInvalidOAuthCache + } + private var allowsDelegatedOAuthRefresh: Bool { self.runtime == .app } @@ -277,6 +291,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { runtime: ProviderRuntime = .app, dataSource: ClaudeUsageDataSource = .oauth, oauthKeychainPromptCooldownEnabled: Bool = false, + oauthSafeCredentialSourcesOnly: Bool = false, + preserveInvalidOAuthCache: Bool = false, allowBackgroundDelegatedRefresh: Bool = false, useWebExtras: Bool = false, manualCookieHeader: String? = nil, @@ -290,6 +306,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { runtime: runtime, dataSource: dataSource, oauthKeychainPromptCooldownEnabled: oauthKeychainPromptCooldownEnabled, + oauthSafeCredentialSourcesOnly: oauthSafeCredentialSourcesOnly, + preserveInvalidOAuthCache: preserveInvalidOAuthCache, allowBackgroundDelegatedRefresh: allowBackgroundDelegatedRefresh, useWebExtras: useWebExtras, manualCookieHeader: manualCookieHeader, @@ -306,42 +324,29 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { func load(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { do { let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() - - #if DEBUG - let hasCache = if let hasCachedCredentialsOverride = ClaudeUsageFetcher.hasCachedCredentialsOverride { - hasCachedCredentialsOverride - } else if ClaudeUsageFetcher.loadOAuthCredentialsOverride != nil { - false - } else { - ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) - } - #else - let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) - #endif - - let allowKeychainPrompt = promptPolicy.canPromptNow && !hasCache - ClaudeUsageFetcher.logOAuthBootstrapPromptDecision( - allowKeychainPrompt: allowKeychainPrompt, - policy: promptPolicy, - hasCache: hasCache) - let credentialRecord = try await ClaudeUsageFetcher.loadOAuthCredentialRecord( environment: self.fetcher.environment, - allowKeychainPrompt: allowKeychainPrompt, - respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) + allowKeychainPrompt: false, + respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown, + safeCredentialSourcesOnly: self.fetcher.oauthSafeCredentialSourcesOnly, + clearInvalidCache: !self.fetcher.preserveInvalidOAuthCache) let credentials = credentialRecord.credentials try self.validateRequiredOAuthScope(credentials) let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: credentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) - let keychainMatch = ClaudeOAuthCredentialsStore - .claudeKeychainCredentialMatchWithoutPrompt(for: credentialRecord) + // History is scoped by the credential's one-way owner identifier. Do not compare the winning + // credential with Claude Code's foreign Keychain item after a successful request. + let keychainMatch: ClaudeKeychainCredentialMatch = credentialRecord.owner == .claudeCLI + ? .unavailable + : .notApplicable let snapshot = try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: credentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, oauthHistoryOwnerIdentifier: credentialRecord.historyOwnerIdentifier, + oauthCredentialOwner: credentialRecord.owner, oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) @@ -350,18 +355,30 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { oauthAccessToken: credentials.accessToken) } catch let error as CancellationError { throw error + } catch let error where ClaudeOAuthFetchError.isCancellation(error) { + throw error } catch let error as ClaudeUsageError { throw error } catch let error as ClaudeOAuthCredentialsError { if case .refreshDelegatedToClaudeCLI = error { return try await self.loadAfterDelegatedRefresh(allowDelegatedRetry: allowDelegatedRetry) } + // Preserve exact absence as a typed result so the app's explicit OAuth route may use + // its credential-owning CLI fallback. Every other credential error remains terminal. + if case .notFound = error { + throw error + } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { if case .rateLimited = error { throw ClaudeUsageError.oauthFailed(error.localizedDescription) } - ClaudeOAuthCredentialsStore.invalidateCache(environment: self.fetcher.environment) + // Explicit OAuth is an authority boundary. Retain a credential that reached the + // service but failed so a later retry cannot reinterpret it as absence and fall + // through to the ambient CLI. Auto retains its existing invalidation behavior. + if !self.fetcher.preserveInvalidOAuthCache { + ClaudeOAuthCredentialsStore.invalidateCache(environment: self.fetcher.environment) + } if case let .serverError(statusCode, body) = error, statusCode == 403, body?.contains("user:profile") ?? false @@ -426,18 +443,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { delegatedOutcome: delegatedOutcome, didSyncSilently: didSyncSilently, policy: promptPolicy) - let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently - if retryAllowKeychainPrompt { - ClaudeUsageFetcher.log.info( - "Claude OAuth keychain prompt allowed (post-delegation retry)", - metadata: [ - "interaction": promptPolicy.interactionLabel, - "promptMode": promptPolicy.mode.rawValue, - "promptPolicyApplicable": "\(promptPolicy.isApplicable)", - "delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome), - "didSyncSilently": "\(didSyncSilently)", - ]) - } + let retryAllowKeychainPrompt = false if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled { ClaudeUsageFetcher.log.debug( "Claude OAuth credential load (post-delegation retry start)", @@ -456,7 +462,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { try await ClaudeUsageFetcher.loadOAuthCredentialRecord( environment: self.fetcher.environment, allowKeychainPrompt: retryAllowKeychainPrompt, - respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) + respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown, + safeCredentialSourcesOnly: self.fetcher.oauthSafeCredentialSourcesOnly, + clearInvalidCache: !self.fetcher.preserveInvalidOAuthCache) } let refreshedCredentials = refreshedRecord.credentials if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled { @@ -477,19 +485,31 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: refreshedCredentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) - let keychainMatch = ClaudeOAuthCredentialsStore - .claudeKeychainCredentialMatchWithoutPrompt(for: refreshedRecord) + let keychainMatch: ClaudeKeychainCredentialMatch = refreshedRecord.owner == .claudeCLI + ? .unavailable + : .notApplicable let snapshot = try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: refreshedCredentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, oauthHistoryOwnerIdentifier: refreshedRecord.historyOwnerIdentifier, + oauthCredentialOwner: refreshedRecord.owner, oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) return try await self.fetcher.applyWebExtrasIfNeeded( to: snapshot, oauthAccessToken: refreshedCredentials.accessToken) + } catch let error where ClaudeOAuthFetchError.isCancellation(error) { + throw error + } catch let error as ClaudeOAuthCredentialsError + where self.shouldPreserveOwnerCLIHandoff(error) + { + // Claude still owns the expired credential, and no attributable safe source changed after its + // delegated refresh. Preserve the typed handoff so the app's explicit OAuth pipeline can fetch + // usage through the credential-owning CLI instead of trapping the user on the stale cache. Keep + // background recovery fail-closed so this handoff cannot introduce authentication UI. + throw error } catch { ClaudeUsageFetcher.log.debug( "Claude OAuth post-delegation retry failed", @@ -515,6 +535,21 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { + "Web/CLI.") } } + + private func shouldPreserveOwnerCLIHandoff(_ error: ClaudeOAuthCredentialsError) -> Bool { + #if os(macOS) + guard case .refreshDelegatedToClaudeCLI = error else { return false } + #if DEBUG + // The credentials-only test loader cannot provide source/owner provenance. Only the real repository + // can prove that this handoff came from an unchanged Claude-owned cache. + guard ClaudeUsageFetcher.loadOAuthCredentialsOverride == nil else { return false } + #endif + return ProviderInteractionContext.current == .userInitiated + #else + _ = error + return false + #endif + } } private struct StepExecutor { @@ -545,6 +580,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { do { return try await self.execute(step: step, model: model) } catch { + if Task.isCancelled || ClaudeOAuthFetchError.isCancellation(error) { + throw error + } if index < executionSteps.count - 1 { ClaudeUsageFetcher.log.debug( "Claude planner step failed; falling back to next step", @@ -575,10 +613,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { webExtrasEnabled: self.fetcher.useWebExtras, hasWebSession: hasWebSession, hasCLI: hasCLI, - hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable( - runtime: self.fetcher.runtime, - sourceMode: .auto, - environment: self.fetcher.environment))) + // App Auto performs one real OAuth attempt; credential loading is execution, not planning. + hasOAuthCredentials: self.fetcher.runtime == .app)) } private func logAutoPlan(_ plan: ClaudeFetchPlan) { @@ -613,6 +649,13 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private func loadViaAutoCLI(model: String) async throws -> ClaudeUsageSnapshot { + guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.fetcher.environment), + await ClaudeCLIAuthStatusProbe.isLoggedIn( + binary: binary, + environment: self.fetcher.environment) + else { + throw ClaudeUsageError.parseFailed("Claude CLI is not logged in.") + } do { return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) } catch { @@ -834,22 +877,6 @@ extension ClaudeUsageFetcher { extension ClaudeUsageFetcher { // MARK: - OAuth API path - private static func logOAuthBootstrapPromptDecision( - allowKeychainPrompt: Bool, - policy: ClaudeOAuthKeychainPromptPolicy, - hasCache: Bool) - { - guard allowKeychainPrompt else { return } - self.log.info( - "Claude OAuth keychain prompt allowed (bootstrap)", - metadata: [ - "interaction": policy.interactionLabel, - "promptMode": policy.mode.rawValue, - "promptPolicyApplicable": "\(policy.isApplicable)", - "hasCache": "\(hasCache)", - ]) - } - private static func logDeferredBackgroundDelegatedRecoveryIfNeeded( delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, didSyncSilently: Bool, @@ -875,7 +902,9 @@ extension ClaudeUsageFetcher { private static func loadOAuthCredentialRecord( environment: [String: String], allowKeychainPrompt: Bool, - respectKeychainPromptCooldown: Bool) async throws -> ClaudeOAuthCredentialRecord + respectKeychainPromptCooldown: Bool, + safeCredentialSourcesOnly: Bool, + clearInvalidCache: Bool) async throws -> ClaudeOAuthCredentialRecord { #if DEBUG if let override = loadOAuthCredentialsOverride { @@ -888,7 +917,11 @@ extension ClaudeUsageFetcher { return try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh( environment: environment, allowKeychainPrompt: allowKeychainPrompt, - respectKeychainPromptCooldown: respectKeychainPromptCooldown) + respectKeychainPromptCooldown: respectKeychainPromptCooldown, + allowClaudeKeychainRepairWithoutPrompt: !safeCredentialSourcesOnly, + // Explicit OAuth is an authority boundary. Retain malformed safe credentials so a + // retry remains terminal instead of turning corruption into ambient CLI fallback. + clearInvalidCache: clearInvalidCache) } private static func fetchOAuthUsage( @@ -1017,6 +1050,7 @@ extension ClaudeUsageFetcher { credentials: ClaudeOAuthCredentials, oauthKeychainPersistentRefHash: String? = nil, oauthHistoryOwnerIdentifier: String? = nil, + oauthCredentialOwner: ClaudeOAuthCredentialOwner? = nil, oauthKeychainCredentialMismatch: Bool = false, oauthKeychainCredentialAbsent: Bool = false, oauthKeychainCredentialUnavailable: Bool = false) throws -> ClaudeUsageSnapshot @@ -1067,6 +1101,7 @@ extension ClaudeUsageFetcher { rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, + oauthCredentialOwner: oauthCredentialOwner, oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) @@ -1093,6 +1128,7 @@ extension ClaudeUsageFetcher { rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, + oauthCredentialOwner: oauthCredentialOwner, oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) @@ -1281,7 +1317,8 @@ extension ClaudeUsageFetcher { let probe = ClaudeStatusProbe( claudeBinary: claudeBinary, timeout: timeout, - keepCLISessionsAlive: self.keepCLISessionsAlive) + keepCLISessionsAlive: self.keepCLISessionsAlive, + environment: self.environment) let snap = try await probe.fetch() return try Self.makeSnapshot(from: snap) diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index a78dbe474e..93f81bfa76 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -37,6 +37,9 @@ public struct ProviderFetchContext: Sendable { public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? public let providerManualTokenUpdater: ProviderManualTokenUpdater? public let costUsageHistoryDays: Int + /// Restricts a Claude retry to the credential-owning CLI after an ambient account mismatch rejects OAuth. + /// The original source mode remains intact so background interaction gates still apply. + public let claudeOwnerCLIRecoveryOnly: Bool /// Whether warm CLI helper sessions (such as the managed Antigravity `agy` /// process) may outlive a single fetch. True for long-lived hosts (the app, /// `codexbar serve`); false for one-shot CLI invocations that should reset @@ -64,6 +67,7 @@ public struct ProviderFetchContext: Sendable { tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, costUsageHistoryDays: Int = 30, + claudeOwnerCLIRecoveryOnly: Bool = false, persistsCLISessions: Bool = false, persistentCLISessionIdleWindow: TimeInterval? = nil) { @@ -83,6 +87,7 @@ public struct ProviderFetchContext: Sendable { self.tokenAccountTokenUpdater = tokenAccountTokenUpdater self.providerManualTokenUpdater = providerManualTokenUpdater self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) + self.claudeOwnerCLIRecoveryOnly = claudeOwnerCLIRecoveryOnly self.persistsCLISessions = persistsCLISessions self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow } @@ -109,6 +114,8 @@ public struct ProviderFetchResult: Sendable { /// A one-way discriminator derived from the winning Claude OAuth credential. /// Raw access and refresh tokens never enter the fetch result or persisted history. public let claudeOAuthHistoryOwnerIdentifier: String? + /// The authority that owned the winning Claude OAuth credential. This is transient routing evidence only. + public let claudeOAuthCredentialOwner: ClaudeOAuthCredentialOwner? /// Whether a prompt-free comparison proved the winning credential differs from Claude Code's Keychain entry. public let claudeOAuthKeychainCredentialMismatch: Bool /// Whether a prompt-free probe proved Claude Code has no Keychain credential. @@ -126,6 +133,7 @@ public struct ProviderFetchResult: Sendable { diagnostic: String? = nil, claudeOAuthKeychainPersistentRefHash: String? = nil, claudeOAuthHistoryOwnerIdentifier: String? = nil, + claudeOAuthCredentialOwner: ClaudeOAuthCredentialOwner? = nil, claudeOAuthKeychainCredentialMismatch: Bool = false, claudeOAuthKeychainCredentialAbsent: Bool = false, claudeOAuthKeychainCredentialUnavailable: Bool = false) @@ -139,6 +147,7 @@ public struct ProviderFetchResult: Sendable { self.diagnostic = diagnostic self.claudeOAuthKeychainPersistentRefHash = claudeOAuthKeychainPersistentRefHash self.claudeOAuthHistoryOwnerIdentifier = claudeOAuthHistoryOwnerIdentifier + self.claudeOAuthCredentialOwner = claudeOAuthCredentialOwner self.claudeOAuthKeychainCredentialMismatch = claudeOAuthKeychainCredentialMismatch self.claudeOAuthKeychainCredentialAbsent = claudeOAuthKeychainCredentialAbsent self.claudeOAuthKeychainCredentialUnavailable = claudeOAuthKeychainCredentialUnavailable diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e862bb349e..e3f4ff8ceb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -28,31 +28,35 @@ extension CostUsageScanner { options: Options, environment: [String: String] = ProcessInfo.processInfo.environment, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, - fileManager: FileManager = .default) -> [URL] + fileManager: FileManager = .default, + workingDirectory: URL? = nil) -> [URL] { if let override = options.claudeProjectsRoots { return override } var roots: [URL] = [] - if let env = environment["CLAUDE_CONFIG_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !env.isEmpty + if let configuredRoot = environment[ClaudeConfigPaths.configDirectoryEnvironmentKey], + !configuredRoot.isEmpty { - for part in env.split(separator: ",") { - let raw = String(part).trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { continue } - let url = URL(fileURLWithPath: raw) - if url.lastPathComponent == "projects" { - roots.append(url) - } else { - roots.append(url.appendingPathComponent("projects", isDirectory: true)) - } - } + let root = ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: workingDirectory) + roots.append(root.appendingPathComponent("projects", isDirectory: true)) } else { - roots.append(homeDirectory.appendingPathComponent(".config/claude/projects", isDirectory: true)) - roots.append(homeDirectory.appendingPathComponent(".claude/projects", isDirectory: true)) + var pathEnvironment = environment + if pathEnvironment["HOME"]?.isEmpty ?? true { + pathEnvironment["HOME"] = homeDirectory.path + } + let ownerHome = ClaudeConfigPaths.homeDirectory( + environment: pathEnvironment, + workingDirectory: workingDirectory) + let configRoot = ClaudeConfigPaths.configRoot( + environment: pathEnvironment, + workingDirectory: workingDirectory) + roots.append(ownerHome.appendingPathComponent(".config/claude/projects", isDirectory: true)) + roots.append(configRoot.appendingPathComponent("projects", isDirectory: true)) roots.append(contentsOf: ClaudeDesktopProjectsLocator.roots( - homeDirectory: homeDirectory, + homeDirectory: ownerHome, fileManager: fileManager)) } diff --git a/Tests/CodexBarTests/AppAutoVerifierAccountScopeTests.swift b/Tests/CodexBarTests/AppAutoVerifierAccountScopeTests.swift new file mode 100644 index 0000000000..186659ecea --- /dev/null +++ b/Tests/CodexBarTests/AppAutoVerifierAccountScopeTests.swift @@ -0,0 +1,32 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct AppAutoVerifierAccountScopeTests { + @Test + func `ambient account scope ignores configured Claude token accounts`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Configured", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ProviderConfig(id: .claude, tokenAccounts: accounts)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + resolutionScope: .ambientAccount) + + #expect(try tokenContext.resolvedAccounts(for: .claude).isEmpty) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: nil) == .cli) + #expect(tokenContext.effectiveSourceMode(base: .auto, provider: .claude, account: nil) == .auto) + } +} diff --git a/Tests/CodexBarTests/CLIArgumentParsingTests.swift b/Tests/CodexBarTests/CLIArgumentParsingTests.swift index c8ace30e22..6c88dd1453 100644 --- a/Tests/CodexBarTests/CLIArgumentParsingTests.swift +++ b/Tests/CodexBarTests/CLIArgumentParsingTests.swift @@ -66,6 +66,34 @@ struct CLIArgumentParsingTests { #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) } + @Test + func `app auto verifier flag parses through the usage signature`() throws { + let signature = CodexBarCLI._usageSignatureForTesting() + let parser = CommandParser(signature: signature) + let parsed = try parser.parse(arguments: ["--app-auto-verifier"]) + + #expect(parsed.flags.contains("appAutoVerifier")) + } + + @Test(arguments: [ + ["--app-auto-verifier", "--account", "Configured"], + ["--app-auto-verifier", "--account-index", "1"], + ["--app-auto-verifier", "--all-accounts"], + ]) + func `app auto verifier rejects account overrides`(arguments: [String]) throws { + let signature = CodexBarCLI._usageSignatureForTesting() + let parser = CommandParser(signature: signature) + let parsed = try parser.parse(arguments: arguments) + let selection = try CodexBarCLI.decodeTokenAccountSelection(from: parsed) + + #expect(parsed.flags.contains("appAutoVerifier")) + #expect(CodexBarCLI.appAutoVerifierArgumentError( + enabled: true, + providers: [.claude], + sourceMode: .auto, + tokenSelection: selection) != nil) + } + @Test func `diagnose accepts json output flag but discards provider logs`() throws { let signature = CodexBarCLI._diagnoseSignatureForTesting() diff --git a/Tests/CodexBarTests/ClaudeActiveAccountIdentityInvalidationTests.swift b/Tests/CodexBarTests/ClaudeActiveAccountIdentityInvalidationTests.swift new file mode 100644 index 0000000000..e01be946bc --- /dev/null +++ b/Tests/CodexBarTests/ClaudeActiveAccountIdentityInvalidationTests.swift @@ -0,0 +1,1245 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeActiveAccountIdentityInvalidationTests { + @Test + func `ambient identity change clears stale state when a transient fetch fails`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + } + await self.persistIdentity("account-a", in: fixture) + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + tokenSnapshot: fixture.store.tokenSnapshot(for: .claude), + error: fixture.store.error(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot == nil) + #expect(result.resetSnapshot == nil) + #expect(result.tokenSnapshot == nil) + #expect(result.error != nil) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `stable ambient identity preserves cached state on a transient fetch failure`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + } + await self.persistIdentity("account-a", in: fixture) + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-a") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + tokenSnapshot: fixture.store.tokenSnapshot(for: .claude), + error: fixture.store.error(for: .claude)) + } + + #expect(result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.resetSnapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.tokenSnapshot != nil) + #expect(result.error == nil) + } + } + + @Test + func `ambient identity change removes old reset backfill before publishing success`() async throws { + try await self.withMissingCredentialsFile { _ in + let freshSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .auto, + outcome: Self.successOutcome(freshSnapshot)) + } + await self.persistIdentity("account-a", in: fixture) + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot?.updatedAt == freshSnapshot.updatedAt) + #expect(result.snapshot?.primary?.resetsAt == nil) + #expect(result.snapshot?.accountEmail(for: .claude) == "new@example.com") + #expect(result.resetSnapshot?.updatedAt == freshSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `explicit OAuth account switch removes old reset backfill before publishing success`() async throws { + try await self.withMissingCredentialsFile { _ in + let freshSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .oauth, + outcome: Self.successOutcome( + freshSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth)) + } + await self.persistIdentity("account-a", in: fixture) + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + freshSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.successOutcome(freshSnapshot)) + await outcomes.releaseReplacement() + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot?.updatedAt == freshSnapshot.updatedAt) + #expect(result.snapshot?.primary?.resetsAt == nil) + #expect(result.resetSnapshot?.updatedAt == freshSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `missing active identity does not masquerade as an account switch`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + } + await self.persistIdentity("account-a", in: fixture) + + await UsageStore.withActiveClaudeAccountUuidForTesting(nil) { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-a")) + } + } + + @Test + func `first nonnil identity observation seeds without invalidating cached state`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + } + let identities = ClaudeIdentitySequence([nil, "account-b"]) + + await UsageStore.withActiveClaudeAccountUuidResolverForTesting( + { identities.next() }, + { + await fixture.store.refreshProvider(.claude) + }) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `identity switch during fetch invalidates an otherwise cacheable transient failure`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + } + await self.persistIdentity("account-a", in: fixture) + let identities = ClaudeIdentitySequence(["account-a", "account-b"]) + + await UsageStore.withActiveClaudeAccountUuidResolverForTesting( + { identities.next() }, + { + await fixture.store.refreshProvider(.claude) + }) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + + #expect(result.snapshot == nil) + #expect(result.resetSnapshot == nil) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `identity switch during successful fetch discards stale result and publishes replacement`() async throws { + try await self.withMissingCredentialsFile { _ in + let staleInFlightSnapshot = Self.freshSnapshot() + let replacementSnapshot = Self.replacementSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.successOutcome(staleInFlightSnapshot)) + } + await self.persistIdentity("account-a", in: fixture) + let identities = ClaudeIdentitySequence(["account-a", "account-b", "account-b", "account-b"]) + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome(staleInFlightSnapshot), + replacement: Self.successOutcome(replacementSnapshot)) + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidResolverForTesting( + { identities.next() }, + { + let completion = ClaudeRefreshCompletionFlag() + let firstRefresh = Task { @MainActor in + await fixture.store.refreshProvider(.claude) + await completion.markCompleted() + } + let replacementStarted = await self.waitForReplacementStart(outcomes) + #expect(replacementStarted) + #expect(await !(completion.isCompleted())) + + let retiredSnapshot = await MainActor.run { fixture.store.snapshot(for: .claude) } + #expect(retiredSnapshot == nil) + + await outcomes.releaseReplacement() + let replacementPublished = await self.waitForSnapshot( + replacementSnapshot.updatedAt, + in: fixture.store) + #expect(replacementPublished) + await firstRefresh.value + #expect(await completion.isCompleted()) + }) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.snapshot?.updatedAt == replacementSnapshot.updatedAt) + #expect(result.snapshot?.accountEmail(for: .claude) == "replacement@example.com") + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + } + } + + @Test + func `identity disappearance during successful CLI fetch discards stale result and rechecks`() async throws { + try await self.withMissingCredentialsFile { _ in + let staleInFlightSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.successOutcome(staleInFlightSnapshot)) + } + await self.persistIdentity("account-a", in: fixture) + let identities = ClaudeIdentitySequence(["account-a", nil, nil, nil]) + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome(staleInFlightSnapshot), + replacement: Self.transientFailureOutcome()) + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidResolverForTesting( + { identities.next() }, + { + let firstRefresh = Task { @MainActor in + await fixture.store.refreshProvider(.claude) + } + #expect(await self.waitForReplacementStart(outcomes)) + #expect(await MainActor.run { fixture.store.snapshot(for: .claude) } == nil) + + await outcomes.releaseReplacement() + #expect(await self.waitForError(in: fixture.store)) + await firstRefresh.value + }) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.snapshot == nil) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-a")) + } + } + + @Test + func `Auto CLI to Web transition cannot backfill prior account resets`() async throws { + try await self.withMissingCredentialsFile { _ in + let freshSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.successOutcome( + freshSnapshot, + sourceLabel: "web", + strategyKind: .web)) + fixture.store.lastSourceLabels[.claude] = "claude" + return fixture + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-a") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { fixture.store.snapshot(for: .claude) } + #expect(result?.updatedAt == freshSnapshot.updatedAt) + #expect(result?.primary?.resetsAt == nil) + #expect(result?.accountEmail(for: .claude) == "new@example.com") + } + } + + @Test + func `Auto Web to CLI transition cannot backfill prior account resets`() async throws { + try await self.withMissingCredentialsFile { _ in + let freshSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.successOutcome(freshSnapshot)) + fixture.store.lastSourceLabels[.claude] = "web" + return fixture + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-a") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { fixture.store.snapshot(for: .claude) } + #expect(result?.updatedAt == freshSnapshot.updatedAt) + #expect(result?.primary?.resetsAt == nil) + #expect(result?.accountEmail(for: .claude) == "new@example.com") + } + } + + @Test + func `ambient CLI identity change does not retire cached Web result when Web refresh fails`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.transientFailureOutcome()) + fixture.store.lastSourceLabels[.claude] = "web" + return fixture + } + await self.persistIdentity("account-a", in: fixture) + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-a")) + } + } + + @Test(arguments: [ + (ClaudeUsageDataSource.cli, "web"), + (.web, "claude"), + (.api, "oauth"), + ]) + func `failed explicit Claude authority transition retires prior live state`( + source: ClaudeUsageDataSource, + priorSourceLabel: String) async throws + { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: source, + outcome: Self.transientFailureOutcome()) + fixture.store.lastSourceLabels[.claude] = priorSourceLabel + return fixture + } + + await fixture.store.refreshProvider(.claude) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + tokenSnapshot: fixture.store.tokenSnapshot(for: .claude), + error: fixture.store.error(for: .claude)) + } + #expect(result.snapshot == nil) + #expect(result.resetSnapshot == nil) + #expect(result.tokenSnapshot == nil) + #expect(result.error != nil) + } + } + + @Test + func `failed Auto refresh preserves prior state because winning authority is unknown`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.transientFailureOutcome()) + fixture.store.lastSourceLabels[.claude] = "admin-api" + return fixture + } + + await fixture.store.refreshProvider(.claude) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + tokenSnapshot: fixture.store.tokenSnapshot(for: .claude), + error: fixture.store.error(for: .claude)) + } + #expect(result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.resetSnapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.tokenSnapshot != nil) + #expect(result.error == nil) + } + } + + @Test + func `failed selected OAuth authority transition preserves configured account cache`() async throws { + try await self.withMissingCredentialsFile { _ in + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.transientFailureOutcome()) + fixture.settings.addTokenAccount( + provider: .claude, + label: "Saved OAuth", + token: "Bearer sk-ant-oat-saved-token") + let account = try #require(fixture.settings.selectedTokenAccount(for: .claude)) + fixture.store.cacheTokenAccountSnapshot( + provider: .claude, + account: account, + snapshot: fixture.priorSnapshot, + sourceLabel: "admin-api") + return fixture + } + + await fixture.store.refreshProvider(.claude) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + resetSnapshot: fixture.store.lastKnownResetSnapshots[.claude], + tokenSnapshot: fixture.store.tokenSnapshot(for: .claude), + cached: fixture.store.accountSnapshots[.claude], + error: fixture.store.error(for: .claude)) + } + #expect(result.snapshot == nil) + #expect(result.resetSnapshot == nil) + #expect(result.tokenSnapshot == nil) + #expect(result.cached?.count == 1) + #expect(result.cached?.first?.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.error != nil) + } + } + + @Test + func `configured token account cache survives ambient account and credentials file noise`() async throws { + try await self.withMissingCredentialsFile { credentialsURL in + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome()) + fixture.settings.addTokenAccount( + provider: .claude, + label: "Saved OAuth", + token: "Bearer sk-ant-oat-saved-token") + let account = try #require(fixture.settings.selectedTokenAccount(for: .claude)) + fixture.store.cacheTokenAccountSnapshot( + provider: .claude, + account: account, + snapshot: fixture.priorSnapshot, + sourceLabel: "oauth") + return fixture + } + await self.persistIdentity("account-a", in: fixture) + let identities = ClaudeIdentitySequence(["account-a", "account-b"]) + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in + try? FileManager.default.createDirectory( + at: credentialsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try? Data("changed".utf8).write(to: credentialsURL) + return Self.transientFailureOutcome() + } + } + + await UsageStore.withActiveClaudeAccountUuidResolverForTesting( + { identities.next() }, + { + await fixture.store.refreshProvider(.claude) + }) + + let result = await MainActor.run { + ( + cached: fixture.store.accountSnapshots[.claude], + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.cached?.count == 1) + #expect(result.cached?.first?.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-a")) + } + } + + @Test(arguments: [ + ("claude", "admin-api", ProviderFetchKind.apiToken), + ("web", "admin-api", .apiToken), + ("admin-api", "claude", .cli), + ("admin-api", "web", .web), + ("oauth", "claude", .cli), + ("claude", "oauth", .oauth), + ("admin-api", "oauth", .oauth), + ("oauth", "admin-api", .apiToken), + ]) + func `successful Claude authority transition cannot backfill prior resets`( + priorSourceLabel: String, + resultSourceLabel: String, + strategyKind: ProviderFetchKind) async throws + { + try await self.withMissingCredentialsFile { _ in + let freshSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + let fixture = try self.makeFixture( + source: .auto, + outcome: Self.successOutcome( + freshSnapshot, + sourceLabel: resultSourceLabel, + strategyKind: strategyKind)) + fixture.store.lastSourceLabels[.claude] = priorSourceLabel + return fixture + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-a") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { fixture.store.snapshot(for: .claude) } + #expect(result?.updatedAt == freshSnapshot.updatedAt) + #expect(result?.primary?.resetsAt == nil) + #expect(result?.accountEmail(for: .claude) == "new@example.com") + } + } + + @Test + func `account and credential probes share fetch environment profile roots`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-profile-roots-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let alternate = root.appendingPathComponent("alternate", isDirectory: true) + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".claude", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: alternate, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data(#"{"oauthAccount":{"accountUuid":"home-account"}}"#.utf8) + .write(to: home.appendingPathComponent(".claude/.config.json")) + try Data("home".utf8).write(to: home.appendingPathComponent(".claude/.credentials.json")) + try Data(#"{"oauthAccount":{"accountUuid":"alternate-account"}}"#.utf8) + .write(to: alternate.appendingPathComponent(".config.json")) + try Data("alternate".utf8).write(to: alternate.appendingPathComponent(".credentials.json")) + + let homeEnvironment = ["HOME": home.path] + let alternateEnvironment = [ + "HOME": home.path, + "CLAUDE_CONFIG_DIR": alternate.path, + ] + let (homeIdentity, alternateIdentity, homeExpected, alternateExpected, homeFingerprint, alternateFingerprint) = + ClaudeOAuthCredentialsStore + .withEnvironmentCredentialsURLForTesting { + ( + UsageStore._activeClaudeAccountIdentityFromEnvironmentForTesting(homeEnvironment), + UsageStore._activeClaudeAccountIdentityFromEnvironmentForTesting(alternateEnvironment), + UsageStore._activeClaudeAccountIdentityForTesting("home-account", environment: homeEnvironment), + UsageStore._activeClaudeAccountIdentityForTesting( + "alternate-account", + environment: alternateEnvironment), + ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate(environment: homeEnvironment), + ClaudeOAuthCredentialsStore + .currentCredentialsFileFingerprintWithoutPromptForAuthGate( + environment: alternateEnvironment)) + } + + #expect(homeIdentity == homeExpected) + #expect(alternateIdentity == alternateExpected) + #expect(homeIdentity != alternateIdentity) + #expect(homeFingerprint?.contains(home.appendingPathComponent(".claude/.credentials.json").path) == true) + #expect(alternateFingerprint?.contains(alternate.appendingPathComponent(".credentials.json").path) == true) + #expect(homeFingerprint != alternateFingerprint) + } + + @Test(arguments: [ + (ClaudeUsageDataSource.auto, false, false, true), + (.cli, false, true, true), + (.auto, false, true, false), + (.auto, true, false, false), + (.web, false, false, false), + (.api, false, false, false), + (.oauth, false, false, true), + (.oauth, false, true, true), + ]) + func `Claude sources requiring owner corroboration capture active identity`( + source: ClaudeUsageDataSource, + hasSelectedTokenAccount: Bool, + hasAdminAPIKey: Bool, + expected: Bool) + { + #expect(UsageStore.shouldTrackClaudeActiveAccountIdentity( + provider: .claude, + dataSource: source, + hasSelectedTokenAccount: hasSelectedTokenAccount, + hasAdminAPIKey: hasAdminAPIKey) == expected) + } + + private func withMissingCredentialsFile( + _ operation: (URL) async throws -> T) async throws -> T + { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let missingURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("missing-credentials.json") + return try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingURL) { + try await operation(missingURL) + } + } + } + + @MainActor + private func makeFixture( + source: ClaudeUsageDataSource, + outcome: ProviderFetchOutcome, + environment: [String: String] = [:]) throws -> ClaudeIdentityFixture + { + let settings = testSettingsStore(suiteName: "ClaudeActiveAccountIdentityInvalidationTests") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = source + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .claude) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + store._test_providerFetchOutcomeOverride = { _ in outcome } + + let priorSnapshot = Self.priorSnapshot() + store._setSnapshotForTesting(priorSnapshot, provider: .claude) + store.lastKnownResetSnapshots[.claude] = priorSnapshot + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_800_000_001)), + provider: .claude) + return ClaudeIdentityFixture( + store: store, + settings: settings, + priorSnapshot: priorSnapshot) + } + + @MainActor + private func persistIdentity(_ uuid: String, in fixture: ClaudeIdentityFixture) { + fixture.settings.userDefaults.set( + UsageStore._activeClaudeAccountIdentityForTesting(uuid), + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting()) + } + + private static func transientFailureOutcome() -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .failure(ClaudeStatusProbeError.timedOut), + attempts: [ProviderFetchAttempt( + strategyID: "test.cli-timeout", + kind: .cli, + wasAvailable: true, + errorDescription: ClaudeStatusProbeError.timedOut.localizedDescription)]) + } + + private static func successOutcome( + _ snapshot: UsageSnapshot, + sourceLabel: String = "CLI", + strategyKind: ProviderFetchKind = .cli) -> ProviderFetchOutcome + { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: sourceLabel, + strategyID: "test.cli-success", + strategyKind: strategyKind, + claudeOAuthCredentialOwner: strategyKind == .oauth ? .claudeCLI : nil)), + attempts: [ProviderFetchAttempt( + strategyID: "test.cli-success", + kind: .cli, + wasAvailable: true, + errorDescription: nil)]) + } + + private static func priorSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000), + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + } + + private static func freshSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "new@example.com", + accountOrganization: nil, + loginMethod: "Max")) + } + + private static func replacementSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_200), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "replacement@example.com", + accountOrganization: nil, + loginMethod: "Max")) + } + + private static func secondReplacementSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_250), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "second-replacement@example.com", + accountOrganization: nil, + loginMethod: "Max")) + } + + private static func postRewriteOAuthSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_300), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "post-rewrite-oauth@example.com", + accountOrganization: nil, + loginMethod: "Max")) + } + + private func waitForReplacementStart(_ outcomes: ClaudeReplacementFetchSequence) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + if await outcomes.replacementStarted() { + return true + } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + private func waitForSnapshot(_ updatedAt: Date, in store: UsageStore) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + if await MainActor.run(body: { store.snapshot(for: .claude)?.updatedAt == updatedAt }) { + return true + } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + private func waitForError(in store: UsageStore) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + if await MainActor.run(body: { store.error(for: .claude) != nil }) { + return true + } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } +} + +extension ClaudeActiveAccountIdentityInvalidationTests { + @Test + func `active account identity follows and scopes Claude config directory`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data(#"{"oauthAccount":{"accountUuid":"config-account"}}"#.utf8) + .write(to: root.appendingPathComponent(".config.json")) + let environment = ["CLAUDE_CONFIG_DIR": root.path] + + let (observed, expected, defaultIdentity) = ClaudeOAuthCredentialsStore + .withEnvironmentCredentialsURLForTesting { + ( + UsageStore._activeClaudeAccountIdentityFromEnvironmentForTesting(environment), + UsageStore._activeClaudeAccountIdentityForTesting( + "config-account", + environment: environment), + UsageStore._activeClaudeAccountIdentityForTesting("config-account")) + } + + #expect(observed == expected) + #expect(observed != defaultIdentity) + } + + @Test + func `same account identity remains stable when preferred config file appears`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-config-stability-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let accountData = Data(#"{"oauthAccount":{"accountUuid":"stable-account"}}"#.utf8) + try accountData.write(to: root.appendingPathComponent(".claude.json")) + let environment = ["CLAUDE_CONFIG_DIR": root.path] + + let (fallbackIdentity, preferredIdentity) = try ClaudeOAuthCredentialsStore + .withEnvironmentCredentialsURLForTesting { + let fallbackIdentity = UsageStore._activeClaudeAccountIdentityFromEnvironmentForTesting(environment) + try accountData.write(to: root.appendingPathComponent(".config.json")) + return ( + fallbackIdentity, + UsageStore._activeClaudeAccountIdentityFromEnvironmentForTesting(environment)) + } + + #expect(fallbackIdentity == preferredIdentity) + } + + @Test(arguments: [ + (persistedUuid: "stable-account", preservesCachedState: true), + (persistedUuid: "other-account", preservesCachedState: false), + ]) + func `legacy identities migrate only for currently observed account`( + persistedUuid: String, + preservesCachedState: Bool) async throws + { + try await self.withMissingCredentialsFile { _ in + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-config-migration-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fallbackURL = root.appendingPathComponent(".claude.json") + let preferredURL = root.appendingPathComponent(".config.json") + let accountData = Data(#"{"oauthAccount":{"accountUuid":"stable-account"}}"#.utf8) + try accountData.write(to: fallbackURL) + let environment = ["CLAUDE_CONFIG_DIR": root.path] + let fixture = try await MainActor.run { + try self.makeFixture( + source: .cli, + outcome: Self.transientFailureOutcome(), + environment: environment) + } + let legacyIdentity = UsageStore._legacyClaudeActiveAccountIdentityForTesting( + persistedUuid, + accountConfigURL: fallbackURL) + await MainActor.run { + fixture.settings.userDefaults.set( + legacyIdentity, + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting(environment: environment)) + } + try accountData.write(to: preferredURL) + + await fixture.store.refreshProvider(.claude) + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + error: fixture.store.error(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting(environment: environment))) + } + #expect((result.snapshot?.updatedAt == fixture.priorSnapshot.updatedAt) == preservesCachedState) + #expect((result.error == nil) == preservesCachedState) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting( + "stable-account", + environment: environment)) + } + } +} + +extension ClaudeActiveAccountIdentityInvalidationTests { + @Test + func `selecting another Claude profile does not treat its OAuth account as a switch`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-active-account-profiles-\(UUID().uuidString)", isDirectory: true) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-b").path] + let freshSnapshot = Self.freshSnapshot() + + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + let fixture = try await MainActor.run { + try self.makeFixture( + source: .oauth, + outcome: Self.successOutcome( + freshSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + environment: environmentB) + } + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + freshSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.transientFailureOutcome()) + await outcomes.releaseReplacement() + await MainActor.run { + fixture.settings.userDefaults.set( + UsageStore._activeClaudeAccountIdentityForTesting("account-a", environment: environmentA), + forKey: UsageStore.claudeActiveAccountIdentityDefaultsKey) + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + legacyIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore.claudeActiveAccountIdentityDefaultsKey), + profileBIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting( + environment: environmentB))) + } + #expect(result.snapshot?.updatedAt == freshSnapshot.updatedAt) + #expect(await !outcomes.replacementStarted()) + #expect(result.legacyIdentity == UsageStore._activeClaudeAccountIdentityForTesting( + "account-a", + environment: environmentA)) + #expect(result.profileBIdentity == UsageStore._activeClaudeAccountIdentityForTesting( + "account-b", + environment: environmentB)) + } + } + } + + @Test + func `failed owner CLI recovery does not bless the switched account identity`() async throws { + try await self.withMissingCredentialsFile { _ in + let staleOAuthSnapshot = Self.freshSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .auto, + outcome: Self.successOutcome( + staleOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth)) + } + await self.persistIdentity("account-a", in: fixture) + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + staleOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.transientFailureOutcome()) + await outcomes.releaseReplacement() + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + error: fixture.store.error(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.snapshot == nil) + #expect(result.error != nil) + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-a")) + } + } + + @Test + func `pre fetch account switch rejects stale OAuth and publishes owner CLI replacement`() async throws { + try await self.withMissingCredentialsFile { credentialsURL in + try FileManager.default.createDirectory( + at: credentialsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("stale-account-a-credentials".utf8).write(to: credentialsURL) + let staleOAuthSnapshot = Self.freshSnapshot() + let replacementSnapshot = Self.replacementSnapshot() + let fixture = try await MainActor.run { + try self.makeFixture( + source: .auto, + outcome: Self.successOutcome( + staleOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth)) + } + await self.persistIdentity("account-a", in: fixture) + let outcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + staleOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.successOutcome(replacementSnapshot)) + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await outcomes.next() } + } + + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + let completion = ClaudeRefreshCompletionFlag() + let refresh = Task { @MainActor in + await fixture.store.refreshProvider(.claude) + await completion.markCompleted() + } + #expect(await self.waitForReplacementStart(outcomes)) + #expect(await !(completion.isCompleted())) + #expect(await MainActor.run { fixture.store.snapshot(for: .claude) } == nil) + + await outcomes.releaseReplacement() + #expect(await self.waitForSnapshot(replacementSnapshot.updatedAt, in: fixture.store)) + await refresh.value + #expect(await completion.isCompleted()) + } + + let result = await MainActor.run { + ( + snapshot: fixture.store.snapshot(for: .claude), + persistedIdentity: fixture.settings.userDefaults.string( + forKey: UsageStore._claudeActiveAccountIdentityDefaultsKeyForTesting())) + } + #expect(result.snapshot?.updatedAt == replacementSnapshot.updatedAt) + #expect(result.snapshot?.accountEmail(for: .claude) == "replacement@example.com") + #expect(result.persistedIdentity == UsageStore._activeClaudeAccountIdentityForTesting("account-b")) + + #expect(ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth()) + + let secondReplacementSnapshot = Self.secondReplacementSnapshot() + let secondOutcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + staleOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.successOutcome(secondReplacementSnapshot)) + await secondOutcomes.releaseReplacement() + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await secondOutcomes.next() } + } + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + #expect(await secondOutcomes.replacementStarted()) + #expect(await MainActor.run { + fixture.store.snapshot(for: .claude)?.updatedAt == secondReplacementSnapshot.updatedAt + }) + + try Data("rewritten-account-b-credentials-with-a-new-fingerprint".utf8).write(to: credentialsURL) + let postRewriteOAuthSnapshot = Self.postRewriteOAuthSnapshot() + let postRewriteOutcomes = ClaudeReplacementFetchSequence( + first: Self.successOutcome( + postRewriteOAuthSnapshot, + sourceLabel: "OAuth", + strategyKind: .oauth), + replacement: Self.transientFailureOutcome()) + await postRewriteOutcomes.releaseReplacement() + await MainActor.run { + fixture.store._test_providerFetchOutcomeOverride = { _ in await postRewriteOutcomes.next() } + } + await UsageStore.withActiveClaudeAccountUuidForTesting("account-b") { + await fixture.store.refreshProvider(.claude) + } + #expect(await !postRewriteOutcomes.replacementStarted()) + #expect(!ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth()) + #expect(await MainActor.run { + fixture.store.snapshot(for: .claude)?.updatedAt == postRewriteOAuthSnapshot.updatedAt + }) + } + } +} + +@MainActor +private struct ClaudeIdentityFixture { + let store: UsageStore + let settings: SettingsStore + let priorSnapshot: UsageSnapshot +} + +private final class ClaudeIdentitySequence: @unchecked Sendable { + private let lock = NSLock() + private let values: [String?] + private var index = 0 + + init(_ values: [String?]) { + precondition(!values.isEmpty) + self.values = values + } + + func next() -> String? { + self.lock.lock() + defer { self.lock.unlock() } + let value = self.values[min(self.index, self.values.count - 1)] + self.index += 1 + return value + } +} + +private actor ClaudeReplacementFetchSequence { + private let first: ProviderFetchOutcome + private let replacement: ProviderFetchOutcome + private var invocationCount = 0 + private var replacementIsReleased = false + private var releaseContinuations: [CheckedContinuation] = [] + + init(first: ProviderFetchOutcome, replacement: ProviderFetchOutcome) { + self.first = first + self.replacement = replacement + } + + func next() async -> ProviderFetchOutcome { + self.invocationCount += 1 + guard self.invocationCount > 1 else { return self.first } + if !self.replacementIsReleased { + await withCheckedContinuation { continuation in + self.releaseContinuations.append(continuation) + } + } + return self.replacement + } + + func replacementStarted() -> Bool { + self.invocationCount > 1 + } + + func releaseReplacement() { + self.replacementIsReleased = true + let continuations = self.releaseContinuations + self.releaseContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} + +private actor ClaudeRefreshCompletionFlag { + private var completed = false + + func markCompleted() { + self.completed = true + } + + func isCompleted() -> Bool { + self.completed + } +} diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 22a3696dce..fc4018100f 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -4,6 +4,10 @@ import Testing @Suite(.serialized) struct ClaudeBaselineCharacterizationTests { + private enum ExpectedFetchError: Error { + case failed + } + private func makeStubClaudeCLI(loggedIn: Bool = true, invocationLog: URL? = nil) throws -> String { let loggedInJSON = loggedIn ? "true" : "false" return try self.makeStubClaudeCLI( @@ -45,7 +49,8 @@ struct ClaudeBaselineCharacterizationTests { runtime: ProviderRuntime, sourceMode: ProviderSourceMode, env: [String: String] = [:], - settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + settings: ProviderSettingsSnapshot? = nil, + claudeOwnerCLIRecoveryOnly: Bool = false) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( @@ -59,7 +64,8 @@ struct ClaudeBaselineCharacterizationTests { settings: settings, fetcher: UsageFetcher(environment: env), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), - browserDetection: browserDetection) + browserDetection: browserDetection, + claudeOwnerCLIRecoveryOnly: claudeOwnerCLIRecoveryOnly) } private func strategyIDs( @@ -88,7 +94,7 @@ struct ClaudeBaselineCharacterizationTests { private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { let missingCredentialsURL = FileManager.default.temporaryDirectory .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") - return try await KeychainCacheStore.withServiceOverrideForTesting("rat-110-\(UUID().uuidString)") { + return try await KeychainCacheStore.withServiceOverrideForTesting("claude-baseline-\(UUID().uuidString)") { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { @@ -115,7 +121,7 @@ struct ClaudeBaselineCharacterizationTests { } @Test - func `app auto pipeline order is OAuth then CLI then web`() async { + func `app auto pipeline order is safe OAuth then CLI then web`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: true, @@ -130,6 +136,65 @@ struct ClaudeBaselineCharacterizationTests { #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) } + @Test + func `owner CLI recovery retry excludes stale OAuth and unrelated fallbacks`() async throws { + let stubCLIPath = try self.makeStubClaudeCLI() + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: true, + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + runtime: .app, + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": stubCLIPath], + settings: settings, + claudeOwnerCLIRecoveryOnly: true) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["claude.cli"]) + } + + @Test(arguments: [ + ProviderSourceMode.auto, + ProviderSourceMode.api, + ProviderSourceMode.web, + ProviderSourceMode.cli, + ]) + func `selected OAuth token account overrides every global app source`(sourceMode: ProviderSourceMode) async { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .oauth, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let env = [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + "CLAUDE_CLI_PATH": "/usr/bin/true", + ] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let baseContext = self.makeContext(runtime: .app, sourceMode: sourceMode, env: env, settings: settings) + let context = ProviderFetchContext( + runtime: baseContext.runtime, + sourceMode: baseContext.sourceMode, + includeCredits: baseContext.includeCredits, + webTimeout: baseContext.webTimeout, + webDebugDumpHTML: baseContext.webDebugDumpHTML, + verbose: baseContext.verbose, + env: baseContext.env, + settings: baseContext.settings, + fetcher: baseContext.fetcher, + claudeFetcher: baseContext.claudeFetcher, + browserDetection: baseContext.browserDetection, + selectedTokenAccountID: UUID()) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["claude.oauth"]) + #expect(await strategies[0].isAvailable(context)) + } + @Test func `CLI auto pipeline order is web then CLI`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( @@ -145,25 +210,28 @@ struct ClaudeBaselineCharacterizationTests { } @Test - func `explicit CLI pipeline attempts strategy even when planner marks CLI unavailable`() async { + func `app explicit CLI remains available for interactive authentication without preflight`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .cli, webExtrasEnabled: false, cookieSource: .off, manualCookieHeader: nil)) let env = [ - "CLAUDE_CLI_PATH": "/definitely/missing/claude", + "CLAUDE_CLI_PATH": "/usr/bin/true", ] - let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) - let context = self.makeContext(runtime: .app, sourceMode: .cli, env: env, settings: settings) - let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) - - #expect(strategies.map(\.id) == ["claude.cli"]) - #expect(await strategies[0].isAvailable(context)) + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .cli, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["claude.cli"]) + let isAvailable = await strategies[0].isAvailable(context) + #expect(isAvailable) + } } @Test - func `auto pipeline records unavailable planned steps when planner has no executable source`() async { + func `auto pipeline records its OAuth attempt when no fallback source is available`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: true, @@ -171,26 +239,24 @@ struct ClaudeBaselineCharacterizationTests { manualCookieHeader: nil)) let env = ["CLAUDE_CLI_PATH": "/definitely/missing/claude"] - await self.withNoOAuthCredentials { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { - let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) - #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") { + let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) - let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) - switch outcome.result { - case let .failure(error as ProviderFetchError): - switch error { - case let .noAvailableStrategy(provider): - #expect(provider == .claude) - } - case let .failure(error): - Issue.record("Unexpected failure: \(error)") - case let .success(result): - Issue.record("Unexpected success: \(result.sourceLabel)") + switch outcome.result { + case let .failure(error as ClaudeOAuthCredentialsError): + guard case .notFound = error else { + Issue.record("Unexpected OAuth failure: \(error)") + return } + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + case let .success(result): + Issue.record("Unexpected success: \(result.sourceLabel)") } } } @@ -218,7 +284,7 @@ struct ClaudeBaselineCharacterizationTests { settings: settings) #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) } } } @@ -248,7 +314,7 @@ struct ClaudeBaselineCharacterizationTests { env: env, settings: settings) #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) } } } @@ -327,11 +393,67 @@ struct ClaudeBaselineCharacterizationTests { let result = try outcome.result.get() #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, true]) #expect(result.strategyID == "claude.web") #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } + @Test + func `app background auto availability honors stored user action prompt policy`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + + let available = await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await cli.isAvailable(context) + } + } + } + + #expect(!available) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `app background auto availability stops when Keychain access is disabled`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + + let available = await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await cli.isAvailable(context) + } + } + + #expect(!available) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + @Test func `app user initiated auto preserves CLI fallback without auth preflight`() async throws { let settings = ProviderSettingsSnapshot.make(claude: .init( @@ -364,7 +486,16 @@ struct ClaudeBaselineCharacterizationTests { cookieSource: .off, manualCookieHeader: nil)) let stubCLIPath = try self.makeStubClaudeCLI() - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profileRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-background-establishment-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: profileRoot) } + try Data(#"{"oauthAccount":{"accountUuid":"established-account"}}"#.utf8) + .write(to: profileRoot.appendingPathComponent(".config.json"), options: .atomic) + let env = [ + "CLAUDE_CLI_PATH": stubCLIPath, + "CLAUDE_CONFIG_DIR": profileRoot.path, + ] let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) @@ -403,37 +534,78 @@ struct ClaudeBaselineCharacterizationTests { } @Test - func `app auto pipeline retains OAuth bootstrap strategy at startup`() async { + func `failed CLI fetch revokes the account marker captured before an in flight account change`() async throws { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: false, cookieSource: .off, manualCookieHeader: nil)) + let stubCLIPath = try self.makeStubClaudeCLI() + let profileRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-background-revocation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: profileRoot) } + let configURL = profileRoot.appendingPathComponent(".config.json") + let accountA = Data(#"{"oauthAccount":{"accountUuid":"account-a"}}"#.utf8) + let accountB = Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8) + let env = [ + "CLAUDE_CLI_PATH": stubCLIPath, + "CLAUDE_CONFIG_DIR": profileRoot.path, + ] + let strategy = ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: nil, + browserDetection: BrowserDetection(cacheTTL: 0), + hasWebFallback: false) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) - await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthKeychainAccessGate.resetForTesting() - } + try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + try accountB.write(to: configURL, options: .atomic) + ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath, environment: env) + try accountA.write(to: configURL, options: .atomic) + ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath, environment: env) + + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { _, _, _ in + try accountB.write(to: configURL, options: .atomic) + throw ExpectedFetchError.failed + } - await self.withNoOAuthCredentials { - let strategyIDs = await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( - .onlyOnUserAction) - { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await self.strategyIDs(runtime: .app, sourceMode: .auto, settings: settings) - } + await #expect(throws: ExpectedFetchError.self) { + try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await strategy.fetch(context) } } - #expect(strategyIDs.first == "claude.oauth") - #expect(strategyIDs.contains("claude.oauth")) + } + + #expect(ClaudeCLIBackgroundAvailability.isEstablished(binary: stubCLIPath, environment: env)) + try accountA.write(to: configURL, options: .atomic) + #expect(!ClaudeCLIBackgroundAvailability.isEstablished(binary: stubCLIPath, environment: env)) + } + } + + @Test + func `app auto pipeline retains OAuth bootstrap strategy at startup`() async { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + + let strategyIDs = await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await self.strategyIDs( + runtime: .app, + sourceMode: .auto, + env: [ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token"], + settings: settings) + } } } + #expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"]) } @Test @@ -444,10 +616,19 @@ struct ClaudeBaselineCharacterizationTests { cookieSource: .off, manualCookieHeader: nil)) let stubCLIPath = try self.makeStubClaudeCLI() - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profileRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-planned-environment-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: profileRoot) } + try Data(#"{"oauthAccount":{"accountUuid":"planned-account"}}"#.utf8) + .write(to: profileRoot.appendingPathComponent(".config.json"), options: .atomic) + let env = [ + "CLAUDE_CLI_PATH": stubCLIPath, + "CLAUDE_CONFIG_DIR": profileRoot.path, + ] await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath) + ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath, environment: env) await self.withBackgroundKeychainAccess { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { await self.withNoOAuthCredentials { @@ -471,7 +652,7 @@ struct ClaudeBaselineCharacterizationTests { } #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) switch outcome.result { case let .success(result): @@ -491,7 +672,6 @@ struct ClaudeBaselineCharacterizationTests { } @Test(arguments: [ - (ProviderSourceMode.oauth, "claude.oauth"), (ProviderSourceMode.cli, "claude.cli"), (ProviderSourceMode.web, "claude.web"), ]) @@ -503,6 +683,16 @@ struct ClaudeBaselineCharacterizationTests { #expect(strategyIDs == [expectedStrategyID]) } + @Test + func `app explicit OAuth plans direct credentials before owner mediated CLI`() async { + let strategyIDs = await self.strategyIDs( + runtime: .app, + sourceMode: .oauth, + env: ["CLAUDE_CLI_PATH": "/usr/bin/true"]) + + #expect(strategyIDs == ["claude.oauth", "claude.cli"]) + } + @Test(arguments: [ (ProviderSourceMode.oauth, "claude.oauth"), (ProviderSourceMode.cli, "claude.cli"), @@ -512,7 +702,10 @@ struct ClaudeBaselineCharacterizationTests { sourceMode: ProviderSourceMode, expectedStrategyID: String) async { - let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: sourceMode) + let strategyIDs = await self.strategyIDs( + runtime: .cli, + sourceMode: sourceMode, + env: ["CLAUDE_CLI_PATH": "/usr/bin/true"]) #expect(strategyIDs == [expectedStrategyID]) } diff --git a/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift index ecdca5fca6..cdc94c83ba 100644 --- a/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodexBarCore @@ -13,4 +14,38 @@ struct ClaudeCLIAuthStatusProbeTests { #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn("not-json")) #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"authMethod":"none"}"#)) } + + @Test + func `auth status uses the Claude owner working directory for relative profiles`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ClaudeCLIAuthStatusProbe-\(UUID().uuidString)", isDirectory: true) + let workingDirectory = root.appendingPathComponent("probe", isDirectory: true) + let profile = workingDirectory.appendingPathComponent("relative-profile", isDirectory: true) + let invocationLog = root.appendingPathComponent("invocation.log") + let binary = root.appendingPathComponent("claude") + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: profile.appendingPathComponent(".config.json")) + defer { try? FileManager.default.removeItem(at: root) } + + let script = """ + #!/bin/sh + if [ -f "$CLAUDE_CONFIG_DIR/.config.json" ]; then + FOUND=yes + else + FOUND=no + fi + printf '%s|%s\n' "$PWD" "$FOUND" > '\(invocationLog.path)' + printf '%s\n' '{"loggedIn":true,"authMethod":"claude.ai"}' + """ + try script.write(to: binary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + + let loggedIn = await ClaudeCLIAuthStatusProbe.isLoggedIn( + binary: binary.path, + environment: [ClaudeConfigPaths.configDirectoryEnvironmentKey: "relative-profile"], + workingDirectory: workingDirectory) + + #expect(loggedIn) + #expect(try String(contentsOf: invocationLog, encoding: .utf8) == "\(workingDirectory.path)|yes\n") + } } diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift index 72a2ea0808..d677353dce 100644 --- a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -22,12 +22,14 @@ struct ClaudeCLIBackgroundAvailabilityTests { } @Test - func `disabled Keychain allows background Auto after foreground availability is established`() async { + func `disabled Keychain allows background Auto after foreground availability is established`() async throws { let strategy = self.makeStrategy() - let context = self.makeContext() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) await KeychainAccessGate.withTaskOverrideForTesting(true) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { @@ -41,12 +43,14 @@ struct ClaudeCLIBackgroundAvailabilityTests { } @Test - func `background Auto CLI keeps prompt policy after foreground availability is established`() async { + func `background Auto CLI keeps prompt policy after foreground availability is established`() async throws { let strategy = self.makeStrategy() - let context = self.makeContext() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) await KeychainAccessGate.withTaskOverrideForTesting(false) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { @@ -60,12 +64,14 @@ struct ClaudeCLIBackgroundAvailabilityTests { } @Test - func `background Auto CLI uses foreground availability with explicit prompt opt in`() async { + func `background Auto CLI uses foreground availability with explicit prompt opt in`() async throws { let strategy = self.makeStrategy() - let context = self.makeContext() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) await KeychainAccessGate.withTaskOverrideForTesting(false) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { @@ -78,6 +84,87 @@ struct ClaudeCLIBackgroundAvailabilityTests { } } + @Test(arguments: ClaudeOAuthKeychainPromptMode.allCases) + func `background explicit OAuth never reaches interactive CLI`(promptMode: ClaudeOAuthKeychainPromptMode) async { + let strategy = self.makeStrategy() + let context = self.makeContext(sourceMode: .oauth) + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + + @Test + func `user initiated explicit OAuth retains interactive CLI recovery`() async { + let strategy = self.makeStrategy() + let context = self.makeContext(sourceMode: .oauth) + + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(await strategy.isAvailable(context)) + } + } + } + + @Test + func `background Auto availability does not cross config profiles`() async throws { + let strategy = self.makeStrategy() + let profileA = try self.makeProfile(accountID: "account-a") + let profileB = try self.makeProfile(accountID: "account-b") + defer { + try? FileManager.default.removeItem(at: profileA.root) + try? FileManager.default.removeItem(at: profileB.root) + } + let contextA = self.makeContext(environment: profileA.environment) + let contextB = self.makeContext(environment: profileB.environment) + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: contextA.env) + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(contextA)) + #expect(await !strategy.isAvailable(contextB)) + } + } + } + } + } + } + + @Test + func `background Auto availability does not cross active account changes`() async throws { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + + try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) + try await KeychainAccessGate.withTaskOverrideForTesting(true) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + try await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(context)) + try Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8) + .write(to: profile.configURL, options: .atomic) + #expect(await !strategy.isAvailable(context)) + try FileManager.default.removeItem(at: profile.configURL) + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + } + private func makeStrategy() -> ClaudeCLIFetchStrategy { ClaudeCLIFetchStrategy( useWebExtras: false, @@ -87,19 +174,39 @@ struct ClaudeCLIBackgroundAvailabilityTests { hasWebFallback: false) } - private func makeContext() -> ProviderFetchContext { + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + environment: [String: String] = [:]) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: .app, - sourceMode: .auto, + sourceMode: sourceMode, includeCredits: false, webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: [:], + env: environment, settings: nil, - fetcher: UsageFetcher(environment: [:]), - claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + fetcher: UsageFetcher(environment: environment), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection, environment: environment), browserDetection: browserDetection) } + + private func makeProfile(accountID: String) throws -> ( + root: URL, + configURL: URL, + environment: [String: String]) + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-background-profile-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let configURL = root.appendingPathComponent(".config.json") + try Data(#"{"oauthAccount":{"accountUuid":"\#(accountID)"}}"#.utf8) + .write(to: configURL, options: .atomic) + return ( + root: root, + configURL: configURL, + environment: ["CLAUDE_CONFIG_DIR": root.path]) + } } diff --git a/Tests/CodexBarTests/ClaudeCLISessionTests.swift b/Tests/CodexBarTests/ClaudeCLISessionTests.swift index 6aa13db62e..b3acee6ff7 100644 --- a/Tests/CodexBarTests/ClaudeCLISessionTests.swift +++ b/Tests/CodexBarTests/ClaudeCLISessionTests.swift @@ -3,6 +3,199 @@ import Testing @testable import CodexBarCore struct ClaudeCLISessionTests { + @Test + func `Claude session reuse requires explicit request and account scoped ownership`() { + #expect(!ClaudeStatusProbe.shouldKeepCLISessionAlive(requested: false)) + #expect(ClaudeStatusProbe.shouldKeepCLISessionAlive(requested: true)) + } + + @Test + func `Claude session scope changes with account and config root and fails closed without identity`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-scope-\(UUID().uuidString)", isDirectory: true) + let firstRoot = root.appendingPathComponent("first", isDirectory: true) + let secondRoot = root.appendingPathComponent("second", isDirectory: true) + try FileManager.default.createDirectory(at: firstRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let firstEnvironment = ["CLAUDE_CONFIG_DIR": firstRoot.path] + let secondEnvironment = ["CLAUDE_CONFIG_DIR": secondRoot.path] + let configURL = firstRoot.appendingPathComponent(".config.json") + try Data(#"{"oauthAccount":{"accountUuid":"account-a"}}"#.utf8).write(to: configURL) + let accountA = ClaudeAccountProfile.sessionScope(environment: firstEnvironment) + let accountARepeat = ClaudeAccountProfile.sessionScope(environment: firstEnvironment) + + try Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8).write(to: configURL) + let accountB = ClaudeAccountProfile.sessionScope(environment: firstEnvironment) + try Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8) + .write(to: secondRoot.appendingPathComponent(".config.json")) + let accountBInSecondRoot = ClaudeAccountProfile.sessionScope(environment: secondEnvironment) + let secureRoot = root.appendingPathComponent("secure", isDirectory: true) + let accountBInDifferentSecureRoot = ClaudeAccountProfile.sessionScope(environment: [ + "CLAUDE_CONFIG_DIR": firstRoot.path, + "CLAUDE_SECURESTORAGE_CONFIG_DIR": secureRoot.path, + ]) + try FileManager.default.removeItem(at: configURL) + let firstFallbackID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let secondFallbackID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + let missingFirst = ClaudeAccountProfile.sessionScope( + environment: firstEnvironment, + fallbackID: firstFallbackID) + let missingSecond = ClaudeAccountProfile.sessionScope( + environment: firstEnvironment, + fallbackID: secondFallbackID) + + #expect(accountA == accountARepeat) + #expect(accountA != accountB) + #expect(accountB != accountBInSecondRoot) + #expect(accountB != accountBInDifferentSecureRoot) + #expect(missingFirst != missingSecond) + } + + @Test + func `profile environment launches and identifies the reusable session`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-environment-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let logURL = directory.appendingPathComponent("launches.log") + let cliURL = try Self.makeEnvironmentEchoingClaudeCLI(in: directory, logURL: logURL) + let session = ClaudeCLISession() + let firstConfig = directory.appendingPathComponent("profile-a", isDirectory: true) + let firstSecureStorage = directory.appendingPathComponent("secure-a", isDirectory: true) + let firstHome = directory.appendingPathComponent("home-a", isDirectory: true) + let secondConfig = directory.appendingPathComponent("profile-b", isDirectory: true) + let secondSecureStorage = directory.appendingPathComponent("secure-b", isDirectory: true) + let secondHome = directory.appendingPathComponent("home-b", isDirectory: true) + let firstStaleTranscript = try Self.makeStaleProbeTranscript(in: firstConfig) + let secondStaleTranscript = try Self.makeStaleProbeTranscript(in: secondConfig) + + var firstEnvironment = ProcessInfo.processInfo.environment + firstEnvironment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] = "1" + firstEnvironment["CLAUDE_CONFIG_DIR"] = firstConfig.path + firstEnvironment["CLAUDE_SECURESTORAGE_CONFIG_DIR"] = firstSecureStorage.path + firstEnvironment["HOME"] = firstHome.path + + var secondEnvironment = firstEnvironment + secondEnvironment["CLAUDE_CONFIG_DIR"] = secondConfig.path + secondEnvironment["CLAUDE_SECURESTORAGE_CONFIG_DIR"] = secondSecureStorage.path + secondEnvironment["HOME"] = secondHome.path + + do { + let first = try await session.capture( + subcommand: "/status", + binary: cliURL.path, + timeout: 2, + environment: firstEnvironment, + idleTimeout: 0.1, + settleAfterStop: 0) + let second = try await session.capture( + subcommand: "/status", + binary: cliURL.path, + timeout: 2, + environment: secondEnvironment, + idleTimeout: 0.1, + settleAfterStop: 0) + let reused = try await session.capture( + subcommand: "/status", + binary: cliURL.path, + timeout: 2, + environment: secondEnvironment, + idleTimeout: 0.1, + settleAfterStop: 0) + await session.reset() + + #expect(first.contains("Account: \(firstConfig.path)")) + #expect(second.contains("Account: \(secondConfig.path)")) + #expect(reused.contains("Account: \(secondConfig.path)")) + } catch { + await session.reset() + throw error + } + + let launches = try String(contentsOf: logURL, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + #expect(launches == [ + "start:\(firstConfig.path):\(firstSecureStorage.path):\(firstHome.path)", + "start:\(secondConfig.path):\(secondSecureStorage.path):\(secondHome.path)", + ]) + #expect(!FileManager.default.fileExists(atPath: firstStaleTranscript.path)) + #expect(!FileManager.default.fileExists(atPath: secondStaleTranscript.path)) + } + + @Test + func `overlapping profile captures serialize PTY ownership`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-overlap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let logURL = directory.appendingPathComponent("launches.log") + let cliURL = try Self.makeEnvironmentEchoingClaudeCLI(in: directory, logURL: logURL) + let session = ClaudeCLISession() + let firstConfig = directory.appendingPathComponent("profile-a", isDirectory: true) + let secondConfig = directory.appendingPathComponent("profile-b", isDirectory: true) + + var firstEnvironment = ProcessInfo.processInfo.environment + firstEnvironment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] = "1" + firstEnvironment["CLAUDE_CONFIG_DIR"] = firstConfig.path + var secondEnvironment = firstEnvironment + secondEnvironment["CLAUDE_CONFIG_DIR"] = secondConfig.path + + let firstTask = Task { + try await session.capture( + subcommand: "/status", + binary: cliURL.path, + timeout: 5, + environment: firstEnvironment, + idleTimeout: 0.1, + settleAfterStop: 0) + } + do { + try await Self.waitForLaunchCount(1, at: logURL) + } catch { + firstTask.cancel() + await session.reset() + throw error + } + + let secondTask = Task { + try await session.capture( + subcommand: "/status", + binary: cliURL.path, + timeout: 5, + environment: secondEnvironment, + idleTimeout: 0.1, + settleAfterStop: 0) + } + + do { + let first = try await firstTask.value + let second = try await secondTask.value + await session.reset() + + #expect(first.contains("Account: \(firstConfig.path)")) + #expect(!first.contains("Account: \(secondConfig.path)")) + #expect(second.contains("Account: \(secondConfig.path)")) + } catch { + firstTask.cancel() + secondTask.cancel() + await session.reset() + throw error + } + + let launches = try String(contentsOf: logURL, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + #expect(launches.map { $0.split(separator: ":")[1] } == [ + Substring(firstConfig.path), + Substring(secondConfig.path), + ]) + } + @Test func `probe launch reuses one persisted session identifier`() throws { let directory = FileManager.default.temporaryDirectory @@ -16,6 +209,7 @@ struct ClaudeCLISessionTests { #expect(ClaudeCLISession.launchArguments(sessionID: first) == [ "--allowed-tools", "", + "--strict-mcp-config", "--session-id", first.uuidString.lowercased(), ]) @@ -55,4 +249,52 @@ struct ClaudeCLISessionTests { #expect(first == second) } + + private static func makeEnvironmentEchoingClaudeCLI(in directory: URL, logURL: URL) throws -> URL { + let url = directory.appendingPathComponent("claude") + let script = """ + #!/bin/sh + printf 'start:%s:%s:%s\n' \ + "$CLAUDE_CONFIG_DIR" \ + "$CLAUDE_SECURESTORAGE_CONFIG_DIR" \ + "$HOME" >> '\(logURL.path)' + while IFS= read -r line; do + case "$line" in + *"/status"*) + printf 'Account: %s\n' "$CLAUDE_CONFIG_DIR" + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + private static func makeStaleProbeTranscript(in configDirectory: URL) throws -> URL { + let projectDirectory = configDirectory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName( + for: ClaudeStatusProbe.probeWorkingDirectoryURL()), + isDirectory: true) + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + let transcript = projectDirectory.appendingPathComponent("stale.jsonl") + try Data("{}\n".utf8).write(to: transcript) + return transcript + } + + private static func waitForLaunchCount(_ expectedCount: Int, at logURL: URL) async throws { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let count = (try? String(contentsOf: logURL, encoding: .utf8))? + .split(separator: "\n") + .count ?? 0 + if count >= expectedCount { + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + throw ClaudeCLISession.SessionError.timedOut + } } diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift index f201b597bc..d946f7eb06 100644 --- a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -80,6 +80,8 @@ struct ClaudeCLITimeoutRetryTests { @Test func `auto cli usage does not retry unrecoverable parse failure`() async throws { let attempts = AttemptRecorder() + let cliPath = try Self.makeLoggedInClaudeCLI() + defer { try? FileManager.default.removeItem(at: cliPath) } let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), environment: [:], @@ -93,7 +95,7 @@ struct ClaudeCLITimeoutRetryTests { await #expect(throws: ClaudeStatusProbeError.self) { try await self.withNoOAuthCredentials { - try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(cliPath.path) { try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { try await fetcher.loadLatestUsage(model: "sonnet") } @@ -110,6 +112,8 @@ struct ClaudeCLITimeoutRetryTests { func `auto cli usage retries loading panel before stale web fallback`() async throws { let attempts = AttemptRecorder() let webRequests = WebRequestRecorder() + let cliPath = try Self.makeLoggedInClaudeCLI() + defer { try? FileManager.default.removeItem(at: cliPath) } let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), environment: [:], @@ -139,7 +143,7 @@ struct ClaudeCLITimeoutRetryTests { webRequests.record(request.url?.path ?? "") throw URLError(.userAuthenticationRequired) }, operation: { - try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(cliPath.path) { try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { try await fetcher.loadLatestUsage(model: "sonnet") } @@ -159,6 +163,8 @@ struct ClaudeCLITimeoutRetryTests { @Test func `auto cli usage retries timeout when cli is final source`() async throws { let attempts = AttemptRecorder() + let cliPath = try Self.makeLoggedInClaudeCLI() + defer { try? FileManager.default.removeItem(at: cliPath) } let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), environment: [:], @@ -184,7 +190,7 @@ struct ClaudeCLITimeoutRetryTests { } let snapshot = try await self.withNoOAuthCredentials { - try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(cliPath.path) { try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { try await fetcher.loadLatestUsage(model: "sonnet") } @@ -364,4 +370,19 @@ struct ClaudeCLITimeoutRetryTests { } return try await operation() } + + private static func makeLoggedInClaudeCLI() throws -> URL { + let executable = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auth-status-\(UUID().uuidString)") + try Data(""" + #!/bin/sh + if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + printf '%s\\n' '{"loggedIn":true,"authMethod":"claude.ai"}' + exit 0 + fi + exit 88 + """.utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + return executable + } } diff --git a/Tests/CodexBarTests/ClaudeCredentialOwnershipBoundaryTests.swift b/Tests/CodexBarTests/ClaudeCredentialOwnershipBoundaryTests.swift new file mode 100644 index 0000000000..0425c6b1cc --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCredentialOwnershipBoundaryTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeCredentialOwnershipBoundaryTests { + @Test(arguments: [ + ClaudeOAuthKeychainPromptMode.never, + ClaudeOAuthKeychainPromptMode.onlyOnUserAction, + ClaudeOAuthKeychainPromptMode.always, + ]) + func `production ownership boundary rejects Claude Code keychain under every prompt mode`( + mode: ClaudeOAuthKeychainPromptMode) + { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(mode) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + #expect(ClaudeOAuthCredentialsStore.directClaudeCodeKeychainAccessAllowedForTesting == false) + + let data = ClaudeOAuthCredentialsStore.readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( + interaction: .userInitiated, + readStrategy: .securityCLIExperimental) + #expect(data == nil) + #expect( + ClaudeOAuthCredentialsStore.readRawClaudeKeychainPayloadViaSecurityFrameworkWithoutPrompt() + == nil) + #expect(ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() == false) + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift index 588d8f6b50..a58a31600d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift @@ -596,4 +596,48 @@ struct ClaudeOAuthCredentialsProfileCacheTests { } } } + + @Test + func `rejected credentials file stays quarantined per profile until its fingerprint changes`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let fileA = profileA.appendingPathComponent(".credentials.json") + let fileB = profileB.appendingPathComponent(".credentials.json") + let staleA = self.makeCredentialsData(accessToken: "stale-profile-a-token") + let freshA = self.makeCredentialsData(accessToken: "fresh-profile-a-token-with-new-size") + let profileBData = self.makeCredentialsData(accessToken: "profile-b-token") + try staleA.write(to: fileA) + try profileBData.write(to: fileB) + + try self.withIsolatedCache { + #expect(ClaudeOAuthCredentialsStore.quarantineCurrentCredentialsFileForOAuth( + environment: environmentA)) + #expect(ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth( + environment: environmentA)) + #expect(!ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth( + environment: environmentB)) + #expect(try ClaudeOAuthCredentialsStore.loadFromFile(environment: environmentB) == profileBData) + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthCredentialsStore.loadFromFile(environment: environmentA) + } + guard case .notFound = error else { + Issue.record("Expected the quarantined file to fail closed as not found") + return + } + + try freshA.write(to: fileA) + + #expect(!ClaudeOAuthCredentialsStore.isCurrentCredentialsFileQuarantinedForOAuth( + environment: environmentA)) + #expect(try ClaudeOAuthCredentialsStore.loadFromFile(environment: environmentA) == freshA) + } + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift index 9876f305db..dc967ef2b1 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -86,7 +86,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } @Test - func `successful codexbar refresh is re-owned when Claude CLI storage appears`() async throws { + func `successful codexbar refresh is re-owned when matching Claude CLI storage appears`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) @@ -105,17 +105,21 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( - profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( - environment: [:])) - defer { KeychainCacheStore.clear(key: cacheKey) } + let legacyCacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let profileCacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore + .credentialsProfileIdentifier(environment: [:])) + defer { + KeychainCacheStore.clear(key: legacyCacheKey) + KeychainCacheStore.clear(key: profileCacheKey) + } let expiredData = self.makeCredentialsData( accessToken: "expired-codexbar-only", expiresAt: Date(timeIntervalSinceNow: -3600), refreshToken: "cached-refresh-token") KeychainCacheStore.store( - key: cacheKey, + key: legacyCacheKey, entry: ClaudeOAuthCredentialsStore.CacheEntry( data: expiredData, storedAt: Date(timeIntervalSinceNow: 60), @@ -165,7 +169,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { #expect(tokenRefreshRequestCount == 1) switch KeychainCacheStore.load( - key: cacheKey, + key: profileCacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { case let .found(entry): @@ -178,12 +182,19 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } let keychainData = self.makeCredentialsData( - accessToken: "claude-keychain", + accessToken: "fresh-codexbar-token", expiresAt: Date(timeIntervalSinceNow: 3600), - refreshToken: "keychain-refresh-token") + refreshToken: "fresh-refresh-token") + let keychainFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "matching-keychain-item") let recordAfterCLIStorageAppears = try ClaudeOAuthCredentialsStore - .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) { + .withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: keychainFingerprint) + { try ClaudeOAuthCredentialsStore.loadRecord( environment: [:], allowKeychainPrompt: false, @@ -224,6 +235,14 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } ClaudeOAuthCredentialsStore.invalidateCache() + let legacyCacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let profileCacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore + .credentialsProfileIdentifier(environment: [:])) + defer { + KeychainCacheStore.clear(key: legacyCacheKey) + KeychainCacheStore.clear(key: profileCacheKey) + } let expiredData = self.makeCredentialsData( accessToken: "access-before-rotation", expiresAt: Date(timeIntervalSinceNow: -3600), @@ -231,7 +250,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { let originalCredentials = try ClaudeOAuthCredentials.parse(data: expiredData) let originalHistoryOwner = try #require(originalCredentials.historyOwnerIdentifier) KeychainCacheStore.store( - key: cacheKey, + key: legacyCacheKey, entry: ClaudeOAuthCredentialsStore.CacheEntry( data: expiredData, storedAt: Date(), @@ -270,7 +289,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { #expect(refreshedRecord.historyOwnerIdentifier == originalHistoryOwner) switch KeychainCacheStore.load( - key: cacheKey, + key: profileCacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { case let .found(entry): @@ -469,7 +488,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } @Test - func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws { + func `unrelated global Claude keychain item cannot re-own profile cache`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) @@ -486,7 +505,10 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:]) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: profileIdentifier) defer { KeychainCacheStore.clear(key: cacheKey) } let cachedData = self.makeCredentialsData( @@ -498,18 +520,23 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { entry: ClaudeOAuthCredentialsStore.CacheEntry( data: cachedData, storedAt: Date(), - owner: .codexbar)) + owner: .codexbar, + profileIdentifier: profileIdentifier)) let keychainData = self.makeCredentialsData( accessToken: "claude-keychain", expiresAt: Date(timeIntervalSinceNow: 3600), refreshToken: "keychain-refresh-token") + let keychainFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "unrelated-profile-keychain-item") let record = try ClaudeOAuthKeychainPromptPreference .withTaskOverrideForTesting(.onlyOnUserAction) { try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( data: keychainData, - fingerprint: nil) + fingerprint: keychainFingerprint) { try ClaudeOAuthCredentialsStore.loadRecord( environment: [:], @@ -520,7 +547,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } #expect(record.credentials.accessToken == "codexbar-cache") - #expect(record.owner == .claudeCLI) + #expect(record.owner == .codexbar) #expect(record.source == .cacheKeychain) } } @@ -672,6 +699,62 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } } } + + @Test + func `selected profile file ignores unrelated global mcp O auth state`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profile = root.appendingPathComponent("selected-profile", isDirectory: true) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environment = ["CLAUDE_CONFIG_DIR": profile.path] + let expiredData = self.makeCredentialsData( + accessToken: "selected-profile-expired", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "selected-profile-refresh") + try expiredData.write(to: ClaudeConfigPaths.credentialsURL(environment: environment)) + let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"other-profile"}}}"#.utf8) + + await self.withDeterministicCacheService(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(mcpOAuthOnly)) + { + do { + _ = try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + Issue.record("Expected selected-profile delegated refresh") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + } } private final class ClaudeOAuthTokenRefreshStubURLProtocol: URLProtocol { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift index a7d454294a..93a40e1791 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift @@ -622,7 +622,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { } @Test - func `never mode bypasses oauth cache while preserving experimental security CLI reader`() throws { + func `owned cache disabled still rejects ambient experimental repair`() throws { try self.withTestState { state in try self.withCredentialsFile(data: nil) { _ in self.seedCache(state, accessToken: "cached-token") @@ -631,26 +631,39 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { expiresAt: Date(timeIntervalSinceNow: 3600), refreshToken: "security-cli-refresh-token") - let credentials = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: securityData, - fingerprint: nil) - { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: false) + final class ReadCounter: @unchecked Sendable { + var hits = 0 + } + let securityReadCalls = ReadCounter() + + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + securityReadCalls.hits += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: securityData, + fingerprint: nil) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } } } } } } - - #expect(credentials.accessToken == "security-cli-token") + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + #expect(securityReadCalls.hits == 0) #expect(state.recorder.operations.isEmpty) #expect(state.pendingStore.isPending) let cachedToken = try self.cachedToken(state) diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 663dfe62da..13ff13e0d7 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -23,6 +23,11 @@ struct ClaudeOAuthCredentialsStoreTests { return Data(json.utf8) } + private func profileCacheKey(environment: [String: String] = [:]) -> KeychainCacheStore.Key { + ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment)) + } + @Test func `persistent reference hash stays stable across keychain metadata refresh`() { let first = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( @@ -1042,37 +1047,39 @@ extension ClaudeOAuthCredentialsStoreTests { } @Test - func `never mode repairs a missing credentials file from a valid no-UI Keychain read`() throws { + func `never mode does not repair a missing credentials file from Claude-owned Keychain`() throws { try self.withIsolatedOAuthCache { try self.withMissingCredentialsFile { let keychainData = self.makeCredentialsData( accessToken: "test-token-placeholder", expiresAt: Date(timeIntervalSinceNow: 3600)) - let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: keychainData, - fingerprint: nil) - { - try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: false, - allowClaudeKeychainRepairWithoutPrompt: true) + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } } } } - - #expect(record.credentials.accessToken == "test-token-placeholder") - #expect(record.source == .claudeKeychain) - #expect(record.owner == .claudeCLI) + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } } } } @Test - func `never mode skips the experimental security CLI before no-UI Keychain repair`() throws { + func `never mode skips all ambient Keychain readers on cache miss`() throws { try self.withIsolatedOAuthCache { try self.withMissingCredentialsFile { let noUIData = self.makeCredentialsData( @@ -1083,37 +1090,43 @@ extension ClaudeOAuthCredentialsStoreTests { expiresAt: Date(timeIntervalSinceNow: 3600)) final class ReadCounter: @unchecked Sendable { var count = 0 + var isEmpty: Bool { + self.count == .zero + } } let securityCLIReads = ReadCounter() - let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental) - { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in - securityCLIReads.count += 1 - return securityCLIData - }) { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: noUIData, - fingerprint: nil) - { - try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: false, - allowClaudeKeychainRepairWithoutPrompt: true) + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityCLIReads.count += 1 + return securityCLIData + }) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: noUIData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } } - } + } } } } - - #expect(record.credentials.accessToken == "test-token-placeholder") - #expect(record.source == .claudeKeychain) - #expect(securityCLIReads.count < 1) + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + #expect(securityCLIReads.isEmpty) } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshMCPProfileTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshMCPProfileTests.swift new file mode 100644 index 0000000000..69390f4b37 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshMCPProfileTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class ClaudeDelegatedProfileTouchCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + self.lock.withLock { self.value += 1 } + } + + func count() -> Int { + self.lock.withLock { self.value } + } +} + +@Suite(.serialized) +struct ClaudeOAuthDelegatedRefreshMCPProfileTests { + @Test + func `selected profile file lets delegated refresh bypass unrelated global mcp O auth`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profile = root.appendingPathComponent("selected-profile", isDirectory: true) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environment = ["CLAUDE_CONFIG_DIR": profile.path] + try self.makeCredentialsData( + accessToken: "selected-profile-expired", + expiresAt: Date(timeIntervalSinceNow: -3600)) + .write(to: ClaudeConfigPaths.credentialsURL(environment: environment)) + let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"other-profile"}}}"#.utf8) + let refreshedCredentials = self.makeCredentialsData( + accessToken: "global-after-touch", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let touches = ClaudeDelegatedProfileTouchCounter() + let touchAuthPath: @Sendable (TimeInterval, [String: String]) async -> Void = { _, _ in + touches.increment() + } + + let outcome = await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + return await ClaudeOAuthDelegatedRefreshCoordinator + .withCLIAvailableOverrideForTesting(true) { + await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + touches.count() > 0 ? refreshedCredentials : mcpOAuthOnly + }) { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 64000), + timeout: 0.1, + environment: environment) + } + } + } + } + } + } + } + } + } + + #expect(outcome == .attemptedSucceeded) + #expect(touches.count() == 1) + } + + private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"] + } + } + """.utf8) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshProfileIsolationTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshProfileIsolationTests.swift new file mode 100644 index 0000000000..ca3fc3a29d --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshProfileIsolationTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthDelegatedRefreshProfileIsolationTests { + @Test + func `legacy cooldown migrates only to the default credentials profile`() { + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + let defaults = UserDefaults.standard + let legacyTimestampKey = "claudeOAuthDelegatedRefreshLastAttemptAtV1" + let legacyIntervalKey = "claudeOAuthDelegatedRefreshCooldownIntervalSecondsV1" + let now = Date(timeIntervalSince1970: 68000) + let defaultEnvironment = ProcessInfo.processInfo.environment + let otherEnvironment = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-profile-migration-other"] + let defaultProfileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: defaultEnvironment) + let scopedTimestampKey = legacyTimestampKey + ".profile." + defaultProfileIdentifier + let scopedIntervalKey = legacyIntervalKey + ".profile." + defaultProfileIdentifier + + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + defaults.set(now.timeIntervalSince1970, forKey: legacyTimestampKey) + defaults.set(300.0, forKey: legacyIntervalKey) + + #expect(ClaudeOAuthDelegatedRefreshCoordinator.isInCooldown( + now: now.addingTimeInterval(1), + environment: defaultEnvironment)) + #expect(!ClaudeOAuthDelegatedRefreshCoordinator.isInCooldown( + now: now.addingTimeInterval(1), + environment: otherEnvironment)) + #expect(defaults.object(forKey: scopedTimestampKey) as? Double == now.timeIntervalSince1970) + #expect(defaults.object(forKey: scopedIntervalKey) as? Double == 300.0) + #expect(defaults.object(forKey: legacyTimestampKey) == nil) + #expect(defaults.object(forKey: legacyIntervalKey) == nil) + } + } + + @Test + func `overlapping background profiles refresh independently of each other's cooldown`() async throws { + actor Gate { + private var startedContinuation: CheckedContinuation? + private var joinedContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + private var started = false + private var joined = false + private var released = false + + func markStarted() { + self.started = true + self.startedContinuation?.resume() + self.startedContinuation = nil + } + + func waitStarted() async { + if self.started { + return + } + await withCheckedContinuation { self.startedContinuation = $0 } + } + + func markJoined() { + self.joined = true + self.joinedContinuation?.resume() + self.joinedContinuation = nil + } + + func waitJoined() async { + if self.joined { + return + } + await withCheckedContinuation { self.joinedContinuation = $0 } + } + + func release() { + self.released = true + self.releaseContinuation?.resume() + self.releaseContinuation = nil + } + + func waitRelease() async { + if self.released { + return + } + await withCheckedContinuation { self.releaseContinuation = $0 } + } + } + + final class State: @unchecked Sendable { + private let lock = NSLock() + private var environments: [[String: String]] = [] + private var fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "before") + + func record(_ environment: [String: String]) -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.environments.append(environment) + return self.environments.count + } + + func advanceFingerprint(for attempt: Int) { + self.lock.lock() + self.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: attempt + 1, + createdAt: attempt + 1, + persistentRefHash: "after-\(attempt)") + self.lock.unlock() + } + + func snapshot() -> ([[String: String]], ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint) { + self.lock.lock() + defer { self.lock.unlock() } + return (self.environments, self.fingerprint) + } + } + + let gate = Gate() + let state = State() + let profileA = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-profile-a"] + let profileB = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-profile-b"] + let touchAuthPath: @Sendable (TimeInterval, [String: String]) async throws -> Void = { _, environment in + let attempt = state.record(environment) + if attempt == 1 { + await gate.markStarted() + await gate.waitRelease() + } + state.advanceFingerprint(for: attempt) + } + + let outcomes = try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(true) { + try await ClaudeOAuthDelegatedRefreshCoordinator + .withKeychainFingerprintOverrideForTesting { + state.snapshot().1 + } operation: { + try await ClaudeOAuthDelegatedRefreshCoordinator + .withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + await ClaudeOAuthDelegatedRefreshCoordinator + .withDifferentProfileJoinObserverForTesting { + Task { await gate.markJoined() } + } operation: { + let first = Task { + await ProviderInteractionContext.$current + .withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator + .attempt( + now: Date(timeIntervalSince1970: 70000), + timeout: 2, + environment: profileA) + } + } + await gate.waitStarted() + let second = Task { + await ProviderInteractionContext.$current + .withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator + .attempt( + now: Date(timeIntervalSince1970: 70001), + timeout: 2, + environment: profileB) + } + } + await gate.waitJoined() + await gate.release() + return await (first.value, second.value) + } + } + } + } + } + } + } + } + } + + #expect(outcomes.0 == .attemptedSucceeded) + #expect(outcomes.1 == .attemptedSucceeded) + #expect(state.snapshot().0 == [profileA, profileB]) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyProfileRoutingTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyProfileRoutingTests.swift new file mode 100644 index 0000000000..a762599f99 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyProfileRoutingTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthFetchStrategyProfileRoutingTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `selected profile file ignores unrelated global MCP-only keychain`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profile = root.appendingPathComponent("selected-profile", isDirectory: true) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environment = ["CLAUDE_CONFIG_DIR": profile.path] + try self.makeCredentialsData( + accessToken: "selected-expired", + expiresAt: Date(timeIntervalSinceNow: -60)) + .write(to: ClaudeConfigPaths.credentialsURL(environment: environment)) + + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + let record = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "selected-expired", + refreshToken: "selected-refresh", + expiresAt: Date(timeIntervalSinceNow: -60), + scopes: ["user:profile"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .cacheKeychain) + let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"other-profile"}}}"#.utf8) + let strategy = ClaudeOAuthFetchStrategy() + + let available = await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(record) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: mcpOAuthOnly, + fingerprint: nil) + { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } + } + } + } + } + } + } + + #expect(available) + } + + private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "refreshToken": "selected-refresh", + "expiresAt": \(millis), + "scopes": ["user:profile"] + } + } + """.utf8) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthNoninteractiveCredentialLoadTests.swift b/Tests/CodexBarTests/ClaudeOAuthNoninteractiveCredentialLoadTests.swift new file mode 100644 index 0000000000..45d3a82fba --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthNoninteractiveCredentialLoadTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeOAuthNoninteractiveCredentialLoadTests { + @Test(arguments: [ + ClaudeOAuthKeychainPromptMode.never, + ClaudeOAuthKeychainPromptMode.onlyOnUserAction, + ClaudeOAuthKeychainPromptMode.always, + ]) + func `oauth credential loads are noninteractive under every prompt mode`( + mode: ClaudeOAuthKeychainPromptMode) async throws + { + final class FlagBox: @unchecked Sendable { + var values: [Bool] = [] + } + + for interaction in [ProviderInteraction.background, .userInitiated] { + let flags = FlagBox() + let usageResponse = try Self.makeOAuthUsageResponse() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + let fetchUsage: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in + usageResponse + } + let loadCredentials: @Sendable ([String: String], Bool, Bool) async throws + -> ClaudeOAuthCredentials = { _, allowKeychainPrompt, _ in + flags.values.append(allowKeychainPrompt) + return ClaudeOAuthCredentials( + accessToken: "explicit-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(mode) { + try await ProviderInteractionContext.$current.withValue(interaction) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue( + fetchUsage, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredentials, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) + } + } + + #expect(flags.values == [false]) + } + } + + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + let json = """ + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift b/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift index 33fadf1893..0fd2cc5b5c 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift @@ -54,7 +54,10 @@ struct ClaudeOAuthRateLimitResilienceTests { @Test func `segmented account keeps only its exact O auth cache without recording history`() async throws { let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-segmented", layout: .segmented) - store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") + store.settings.addTokenAccount( + provider: .claude, + label: "Primary", + token: "sk-ant-oat-test-primary") let account = try #require(store.settings.selectedTokenAccount(for: .claude)) let prior = self.snapshot(usedPercent: 31) self.seedAccountSnapshot(store: store, account: account, snapshot: prior) @@ -77,7 +80,10 @@ struct ClaudeOAuthRateLimitResilienceTests { @Test func `edited account cannot reuse its previous O auth cache`() async throws { let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-edited", layout: .segmented) - store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") + store.settings.addTokenAccount( + provider: .claude, + label: "Primary", + token: "sk-ant-oat-test-primary") let original = try #require(store.settings.selectedTokenAccount(for: .claude)) self.seedAccountSnapshot(store: store, account: original, snapshot: self.snapshot(usedPercent: 47)) store.settings.updateTokenAccount( @@ -97,8 +103,14 @@ struct ClaudeOAuthRateLimitResilienceTests { @Test func `stacked accounts keep exact O auth caches without recording cached history`() async throws { let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-stacked", layout: .stacked) - store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") - store.settings.addTokenAccount(provider: .claude, label: "Secondary", token: "test-token-placeholder") + store.settings.addTokenAccount( + provider: .claude, + label: "Primary", + token: "sk-ant-oat-test-primary") + store.settings.addTokenAccount( + provider: .claude, + label: "Secondary", + token: "sk-ant-oat-test-secondary") let accounts = store.settings.tokenAccounts(for: .claude) let primary = try #require(accounts.first) let secondary = try #require(accounts.last) @@ -151,7 +163,6 @@ struct ClaudeOAuthRateLimitResilienceTests { settings.providerDetectionCompleted = true settings.refreshFrequency = .manual settings.statusChecksEnabled = false - settings.claudeUsageDataSource = .oauth settings.claudeOAuthKeychainPromptMode = .never settings.multiAccountMenuLayout = layout let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) @@ -173,7 +184,7 @@ struct ClaudeOAuthRateLimitResilienceTests { branding: baseSpec.descriptor.branding, tokenCost: baseSpec.descriptor.tokenCost, fetchPlan: ProviderFetchPlan( - sourceModes: [.oauth], + sourceModes: [.auto, .oauth], pipeline: ProviderFetchPipeline { _ in [ClaudeOAuthRateLimitStrategy()] }), cli: baseSpec.descriptor.cli) store.providerSpecs[.claude] = ProviderSpec( diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift index bb5c43d498..5dc8225b27 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift @@ -12,6 +12,14 @@ struct ClaudeOAuthRefreshFailureGateTests { private let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1" private let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1" + private func profileKey( + _ base: String, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String + { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + return base + ".profile." + profileIdentifier + } + @Test func `blocks indefinitely when fingerprint unchanged`() { ClaudeOAuthRefreshFailureGate.resetForTesting() @@ -43,6 +51,34 @@ struct ClaudeOAuthRefreshFailureGateTests { } } + @Test + func `global Keychain changes cannot unblock a selected profile`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "profile-a-global-ref"), + credentialsFile: "profile-a-file") + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 1500) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + + fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "profile-b-global-ref"), + credentialsFile: "profile-a-file") + + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20))) + } + } + @Test func `migrates legacy blocked until in past does not block and clears key`() { ClaudeOAuthRefreshFailureGate.resetForTesting() @@ -83,8 +119,8 @@ struct ClaudeOAuthRefreshFailureGateTests { #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == false) #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == false) #expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil) - #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) != nil) - #expect(UserDefaults.standard.integer(forKey: self.transientFailureCountKey) == 2) + #expect(UserDefaults.standard.object(forKey: self.profileKey(self.transientBlockedUntilKey)) != nil) + #expect(UserDefaults.standard.integer(forKey: self.profileKey(self.transientFailureCountKey)) == 2) } } @@ -195,8 +231,62 @@ struct ClaudeOAuthRefreshFailureGateTests { ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start.addingTimeInterval(1)) #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) - #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == true) - #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil) + #expect(UserDefaults.standard.bool(forKey: self.profileKey(self.terminalBlockedKey)) == true) + #expect(UserDefaults.standard.object(forKey: self.profileKey(self.transientBlockedUntilKey)) == nil) + } + } + + @Test + func `failure state and recovery fingerprints are isolated by credentials profile`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let environmentA = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-refresh-gate-profile-a"] + let environmentB = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-refresh-gate-profile-b"] + let fingerprintB = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: nil, + credentialsFile: "profile-b-file-1") + var fingerprintA = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: nil, + credentialsFile: "profile-a-file-1") + + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthRefreshFailureGate.withEnvironmentFingerprintProviderOverrideForTesting { environment in + environment["CLAUDE_CONFIG_DIR"] == environmentA["CLAUDE_CONFIG_DIR"] + ? fingerprintA + : fingerprintB + } operation: { + let start = Date(timeIntervalSince1970: 40000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(environment: environmentA, now: start) + + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentA, + now: start.addingTimeInterval(20))) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentB, + now: start.addingTimeInterval(20))) + + ClaudeOAuthRefreshFailureGate.recordTransientFailure(environment: environmentB, now: start) + ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentB, + now: start.addingTimeInterval(20))) + + ClaudeOAuthRefreshFailureGate.recordSuccess(environment: environmentB) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentB, + now: start.addingTimeInterval(20))) + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentA, + now: start.addingTimeInterval(40))) + + fingerprintA = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: nil, + credentialsFile: "profile-a-file-2") + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: environmentA, + now: start.addingTimeInterval(60))) + } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index c1086f358c..b2e3ebd63a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -795,7 +795,7 @@ struct ClaudeOAuthTests { // MARK: - Scope-based strategy resolution @Test - func `prefers O auth when available`() { + func `app auto prefers available O auth over CLI`() { let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: .auto, webExtrasEnabled: false, @@ -817,14 +817,14 @@ struct ClaudeOAuthTests { } @Test - func `falls back to web when O auth missing and CLI missing`() { + func `app auto uses available O auth when CLI is missing`() { let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: .auto, webExtrasEnabled: false, hasWebSession: true, hasCLI: false, - hasOAuthCredentials: false) - #expect(strategy.dataSource == .web) + hasOAuthCredentials: true) + #expect(strategy.dataSource == .oauth) } @Test diff --git a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift new file mode 100644 index 0000000000..a7572fcec6 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift @@ -0,0 +1,1222 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +#if os(macOS) +import Security +#endif + +@MainActor +@Suite(.serialized) +// swiftlint:disable:next type_body_length +struct ClaudeOAuthUpgradeCompatibilityTests { + private struct WrongCacheEntry: Codable { + let value: String + } + + private final class CallLog: @unchecked Sendable { + private let lock = NSLock() + private var oauthTokens: [String] = [] + private var webCalls: [String] = [] + private var foreignKeychainReads: Int = 0 + private var delegatedRefreshes: Int = 0 + + func recordOAuthToken(_ token: String) { + self.lock.withLock { self.oauthTokens.append(token) } + } + + func recordWebCall(_ call: String) { + self.lock.withLock { self.webCalls.append(call) } + } + + func recordForeignKeychainRead() { + self.lock.withLock { self.foreignKeychainReads += 1 } + } + + func recordDelegatedRefresh() { + self.lock.withLock { self.delegatedRefreshes += 1 } + } + + var recordedOAuthTokens: [String] { + self.lock.withLock { self.oauthTokens } + } + + var recordedWebCalls: [String] { + self.lock.withLock { self.webCalls } + } + + var recordedForeignKeychainReads: Int { + self.lock.withLock { self.foreignKeychainReads } + } + + var recordedDelegatedRefreshes: Int { + self.lock.withLock { self.delegatedRefreshes } + } + } + + private struct UnexpectedClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + Issue.record("Persisted OAuth must not invoke the Claude CLI fetcher") + throw ClaudeUsageError.parseFailed("unexpected CLI fetch") + } + + func debugRawProbe(model _: String) async -> String { + Issue.record("Persisted OAuth must not invoke the Claude CLI debug probe") + return "unexpected CLI debug probe" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `persisted OAuth uses environment credentials only`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let expectedToken = "environment-oauth-token" + let environment = [ + ClaudeOAuthCredentialsStore.environmentTokenKey: expectedToken, + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ] + + try await self.verifyPersistedOAuthFetch( + suite: "ClaudeOAuthUpgradeCompatibilityTests-environment", + environment: environment, + credentialsURLOverride: missingCredentials, + expectedToken: expectedToken, + cliInvocationLog: cli.invocationLog) + } + + @Test + func `persisted OAuth uses profile file credentials only`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let expectedToken = "profile-file-oauth-token" + let credentialsURL = root.appendingPathComponent(".credentials.json") + try Self.makeCredentialsData(accessToken: expectedToken).write(to: credentialsURL) + let environment = [ + ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path, + "CLAUDE_CLI_PATH": cli.executable.path, + ] + + try await self.verifyPersistedOAuthFetch( + suite: "ClaudeOAuthUpgradeCompatibilityTests-file", + environment: environment, + credentialsURLOverride: nil, + expectedToken: expectedToken, + cliInvocationLog: cli.invocationLog) + } + + @Test + func `app Auto preserves environment OAuth before CLI and Web`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let expectedToken = "auto-environment-oauth-token" + let environment = [ + ClaudeOAuthCredentialsStore.environmentTokenKey: expectedToken, + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ] + + try await self.verifyPersistedOAuthFetch( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-environment", + environment: environment, + credentialsURLOverride: missingCredentials, + expectedToken: expectedToken, + cliInvocationLog: cli.invocationLog, + sourceMode: .auto) + } + + @Test + func `app Auto preserves profile file OAuth before CLI and Web`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let expectedToken = "auto-profile-file-oauth-token" + let credentialsURL = root.appendingPathComponent(".credentials.json") + try Self.makeCredentialsData(accessToken: expectedToken).write(to: credentialsURL) + let environment = [ + ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path, + "CLAUDE_CLI_PATH": cli.executable.path, + ] + + try await self.verifyPersistedOAuthFetch( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-file", + environment: environment, + credentialsURLOverride: nil, + expectedToken: expectedToken, + cliInvocationLog: cli.invocationLog, + sourceMode: .auto) + } + + @Test + func `persisted OAuth uses CodexBar owned cache credentials only`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let expectedToken = "codexbar-cache-oauth-token" + let environment = ["CLAUDE_CLI_PATH": cli.executable.path] + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-cache", + environment: environment) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth", "claude.cli"]) + + let calls = CallLog() + let response = try Self.makeOAuthUsageResponse() + let fetchOAuthUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { token, _ in + calls.recordOAuthToken(token) + return response + } + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: Self.makeCredentialsData(accessToken: expectedToken), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: profileIdentifier)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + return try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOAuthUsage) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.oauth") + #expect(result.sourceLabel == "oauth") + case let .failure(error): + Issue.record("Expected CodexBar cache OAuth fetch to succeed, got \(error)") + } + #expect(calls.recordedOAuthTokens == [expectedToken]) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `app Auto preserves CodexBar owned cache OAuth before CLI and Web`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let expectedToken = "auto-codexbar-cache-oauth-token" + let environment = ["CLAUDE_CLI_PATH": cli.executable.path] + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-cache", + environment: environment, + sourceMode: .auto) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth", "claude.cli", "claude.web"]) + + let calls = CallLog() + let response = try Self.makeOAuthUsageResponse() + let fetchOAuthUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { token, _ in + calls.recordOAuthToken(token) + return response + } + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: Self.makeCredentialsData(accessToken: expectedToken), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: profileIdentifier)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + return try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOAuthUsage) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.oauth") + #expect(result.sourceLabel == "oauth") + case let .failure(error): + Issue.record("Expected Auto CodexBar cache OAuth fetch to succeed, got \(error)") + } + #expect(calls.recordedOAuthTokens == [expectedToken]) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `explicit app OAuth uses owner mediated CLI without auth preflight`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-foreign-only", + environment: ["CLAUDE_CLI_PATH": cli.executable.path]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(context.sourceMode == .oauth) + #expect(context.settings?.claude?.usageDataSource == .oauth) + #expect(strategies.map(\.id) == ["claude.oauth", "claude.cli"]) + guard strategies.map(\.id) == ["claude.oauth", "claude.cli"] else { return } + + let calls = CallLog() + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == cli.executable.path) + return Self.makeCLIUsageSnapshot() + } + let outcome = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(outcome.attempts.first?.errorDescription?.contains("credentials not found") == true) + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + case let .failure(error): + Issue.record("Expected owner-mediated Claude CLI fetch to succeed, got \(error)") + } + #expect(calls.recordedOAuthTokens.isEmpty) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `expired Claude owned cache hands explicit OAuth usage to owner CLI`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let environment = ["CLAUDE_CLI_PATH": cli.executable.path] + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-expired-owner-cache", + environment: environment) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == cli.executable.path) + return Self.makeCLIUsageSnapshot() + } + let delegatedRefresh: @Sendable ( + Date, + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome = { _, _, _ in + calls.recordDelegatedRefresh() + return .attemptedSucceeded + } + + let outcome = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedRefresh) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: Self.makeCredentialsData( + accessToken: "expired-owner-token", + expiresAt: Date(timeIntervalSinceNow: -3600)), + storedAt: Date(), + owner: .claudeCLI, + profileIdentifier: profileIdentifier)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + return try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(outcome.attempts.first?.errorDescription?.contains("delegated to Claude CLI") == true) + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + case let .failure(error): + Issue.record("Expected expired Claude-owned cache to route through the owner CLI, got \(error)") + } + #expect(calls.recordedDelegatedRefreshes == 1) + #expect(calls.recordedOAuthTokens.isEmpty) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test(arguments: ClaudeOAuthKeychainPromptMode.allCases) + func `background explicit OAuth never launches owner CLI`(promptMode: ClaudeOAuthKeychainPromptMode) async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-background-oauth-\(promptMode.rawValue)", + environment: ["CLAUDE_CLI_PATH": cli.executable.path]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth", "claude.cli"]) + + let calls = CallLog() + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + Issue.record("Background explicit OAuth must not invoke the interactive owner CLI") + return Self.makeCLIUsageSnapshot() + } + let outcome = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false]) + switch outcome.result { + case let .failure(error as ClaudeOAuthCredentialsError): + guard case .notFound = error else { + Issue.record("Expected missing OAuth credentials, got \(error)") + return + } + case let .failure(error): + Issue.record("Expected missing OAuth credentials, got \(error)") + case let .success(result): + Issue.record("Background explicit OAuth unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedOAuthTokens.isEmpty) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `background Auto does not launch owner CLI before foreground establishment`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-foreign-only", + environment: ["CLAUDE_CLI_PATH": cli.executable.path], + sourceMode: .auto) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth", "claude.cli", "claude.web"]) + + let calls = CallLog() + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == cli.executable.path) + return Self.makeCLIUsageSnapshot() + } + let outcome = try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) + switch outcome.result { + case let .failure(error as ClaudeOAuthCredentialsError): + guard case .notFound = error else { + Issue.record("Expected missing OAuth credentials, got \(error)") + return + } + case let .failure(error): + Issue.record("Expected missing OAuth credentials, got \(error)") + case let .success(result): + Issue.record("Background Auto unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedOAuthTokens.isEmpty) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `missing app OAuth keeps actionable error when owner CLI is unavailable`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = root.appendingPathComponent("missing-claude") + let cliInvocationLog = root.appendingPathComponent("claude-invocations.log") + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-no-owner-cli", + environment: ["CLAUDE_CLI_PATH": cli.path]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let fetchOutcome: @Sendable () async throws -> ProviderFetchOutcome = { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + let outcome = try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting( + cli.path, + operation: fetchOutcome) + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false]) + switch outcome.result { + case let .failure(error as ClaudeOAuthCredentialsError): + guard case .notFound = error else { + Issue.record("Expected missing OAuth credentials, got \(error)") + return + } + #expect(error.localizedDescription.contains("credentials not found")) + case let .failure(error): + Issue.record("Expected actionable OAuth error, got \(error)") + case let .success(result): + Issue.record("Missing OAuth and CLI unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cliInvocationLog).isEmpty) + } + + @Test + func `malformed profile OAuth remains terminal and does not switch authorities`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + try Data("{ malformed".utf8).write(to: root.appendingPathComponent(".credentials.json")) + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-malformed-file", + environment: [ + ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path, + "CLAUDE_CLI_PATH": cli.executable.path, + ]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: nil) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + if case let .success(result) = outcome.result { + Issue.record("Malformed explicit OAuth unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedOAuthTokens.isEmpty) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `background Auto malformed OAuth does not launch owner CLI before establishment`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + try Data("{ malformed".utf8).write(to: root.appendingPathComponent(".credentials.json")) + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-malformed-file", + environment: [ + ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path, + "CLAUDE_CLI_PATH": cli.executable.path, + ], + sourceMode: .auto) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + Self.makeCLIUsageSnapshot() + } + let outcome = try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: nil) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) + #expect(outcome.attempts.first?.errorDescription?.contains("credentials are invalid") == true) + switch outcome.result { + case let .failure(error): + #expect(error.localizedDescription.contains("credentials are invalid")) + case let .success(result): + Issue.record("Malformed background Auto unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `invalid CodexBar OAuth cache remains terminal and does not switch authorities`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-invalid-cache", + environment: ["CLAUDE_CLI_PATH": cli.executable.path]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let outcomes = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + KeychainCacheStore.store( + key: .oauth(provider: .claude), + entry: WrongCacheEntry(value: "invalid-cache-shape")) + return try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ( + descriptor.fetchOutcome(context: context), + descriptor.fetchOutcome(context: context)) + } + } + } + + for outcome in [outcomes.0, outcomes.1] { + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .failure(error): + #expect(error.localizedDescription.contains("credentials are invalid")) + case let .success(result): + Issue.record("Invalid direct OAuth cache unexpectedly produced \(result.strategyID)") + } + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + #if os(macOS) + @Test + func `unavailable CodexBar OAuth cache remains terminal and does not switch authorities`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-unavailable-cache", + environment: ["CLAUDE_CLI_PATH": cli.executable.path]) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .failure(error): + #expect(error.localizedDescription.contains("temporarily unavailable")) + case let .success(result): + Issue.record("Unavailable direct OAuth cache unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + #endif + + @Test + func `direct OAuth service errors remain terminal and do not switch authorities`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let environment = [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "rate-limited-oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ] + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-service-error", + environment: environment) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let failingOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + } + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(failingOAuth) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + if case let .success(result) = outcome.result { + Issue.record("Rate-limited explicit OAuth unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `failed CodexBar cache OAuth remains terminal across refreshes`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let expectedToken = "cached-oauth-token-that-failed" + let environment = ["CLAUDE_CLI_PATH": cli.executable.path] + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-cache-service-error", + environment: environment) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let failingOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { token, _ in + calls.recordOAuthToken(token) + throw ClaudeOAuthFetchError.unauthorized + } + + let outcomes = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: profileIdentifier) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: Self.makeCredentialsData(accessToken: expectedToken), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: profileIdentifier)) + defer { KeychainCacheStore.clear(key: cacheKey) } + + return try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(failingOAuth) { + await ( + descriptor.fetchOutcome(context: context), + descriptor.fetchOutcome(context: context)) + } + } + } + } + + for outcome in [outcomes.0, outcomes.1] { + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + if case let .success(result) = outcome.result { + Issue.record("Failed cached OAuth unexpectedly produced \(result.strategyID)") + } + } + #expect(calls.recordedOAuthTokens == [expectedToken, expectedToken]) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `background Auto OAuth service error does not launch owner CLI before establishment`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-service-error", + environment: [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "auto-rate-limited-oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ], + sourceMode: .auto) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let failingOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + } + let cliUsage: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + Self.makeCLIUsageSnapshot() + } + let outcome = try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(failingOAuth) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) + switch outcome.result { + case let .failure(error): + #expect(error.localizedDescription.contains("rate limited")) + case let .success(result): + Issue.record("Background Auto OAuth failure unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test(arguments: [false, true]) + func `app Auto OAuth cancellation never changes authorities`(wrappedTransportCancellation: Bool) async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let context = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-cancellation", + environment: [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "auto-cancelled-oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ], + sourceMode: .auto) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let calls = CallLog() + let cancelledOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + if wrappedTransportCancellation { + throw ClaudeOAuthFetchError.networkError(URLError(.cancelled)) + } + throw CancellationError() + } + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(cancelledOAuth) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + switch outcome.result { + case let .success(result): + Issue.record("Cancelled Auto OAuth unexpectedly produced \(result.strategyID)") + case let .failure(error): + #expect(ClaudeOAuthFetchError.isCancellation(error)) + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `selected OAuth account failure never reaches ambient authorities`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let baseContext = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-selected-account", + environment: [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "selected-oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "CLAUDE_CLI_PATH": cli.executable.path, + ]) + let context = ProviderFetchContext( + runtime: baseContext.runtime, + sourceMode: baseContext.sourceMode, + includeCredits: baseContext.includeCredits, + webTimeout: baseContext.webTimeout, + webDebugDumpHTML: baseContext.webDebugDumpHTML, + verbose: baseContext.verbose, + env: baseContext.env, + settings: baseContext.settings, + fetcher: baseContext.fetcher, + claudeFetcher: baseContext.claudeFetcher, + browserDetection: baseContext.browserDetection, + selectedTokenAccountID: UUID()) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth"]) + + let calls = CallLog() + let failingOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + throw ClaudeOAuthFetchError.unauthorized + } + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(failingOAuth) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + if case let .success(result) = outcome.result { + Issue.record("Failed selected OAuth account unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + @Test + func `CLI runtime missing OAuth remains direct and actionable`() async throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let cli = try Self.makeFakeClaudeCLI(in: root) + let missingCredentials = root.appendingPathComponent("missing-credentials.json") + let appContext = try self.makePersistedOAuthContext( + suite: "ClaudeOAuthUpgradeCompatibilityTests-cli-runtime", + environment: ["CLAUDE_CLI_PATH": cli.executable.path]) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .oauth, + includeCredits: appContext.includeCredits, + webTimeout: appContext.webTimeout, + webDebugDumpHTML: appContext.webDebugDumpHTML, + verbose: appContext.verbose, + env: appContext.env, + settings: appContext.settings, + fetcher: appContext.fetcher, + claudeFetcher: appContext.claudeFetcher, + browserDetection: appContext.browserDetection) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + #expect(strategies.map(\.id) == ["claude.oauth"]) + + let calls = CallLog() + let outcome = try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .failure(error): + #expect(error.localizedDescription.contains("credentials not found")) + case let .success(result): + Issue.record("Missing CLI-runtime OAuth unexpectedly produced \(result.strategyID)") + } + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) + } + + private func verifyPersistedOAuthFetch( + suite: String, + environment: [String: String], + credentialsURLOverride: URL?, + expectedToken: String, + cliInvocationLog: URL, + sourceMode: ProviderSourceMode = .oauth) async throws + { + #expect(ClaudeOAuthCredentialsStore.directClaudeCodeKeychainAccessAllowedForTesting == false) + + let context = try self.makePersistedOAuthContext( + suite: suite, + environment: environment, + sourceMode: sourceMode) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + let expectedStrategies = sourceMode == .auto + ? ["claude.oauth", "claude.cli", "claude.web"] + : ["claude.oauth", "claude.cli"] + #expect(context.sourceMode == sourceMode) + #expect(context.settings?.claude?.usageDataSource.rawValue == sourceMode.rawValue) + #expect(strategies.map(\.id) == expectedStrategies) + guard strategies.map(\.id) == expectedStrategies else { return } + + let calls = CallLog() + let response = try Self.makeOAuthUsageResponse() + let fetchOAuthUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { token, _ in + calls.recordOAuthToken(token) + return response + } + let outcome = try await Self.withIsolatedCredentialState( + credentialsURLOverride: credentialsURLOverride) + { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOAuthUsage) { + await descriptor.fetchOutcome(context: context) + } + } + } + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.oauth") + #expect(result.sourceLabel == "oauth") + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.secondary?.usedPercent == 21) + case let .failure(error): + Issue.record("Expected persisted OAuth fetch to succeed, got \(error)") + } + #expect(calls.recordedOAuthTokens == [expectedToken]) + #expect(calls.recordedWebCalls.isEmpty) + #expect(calls.recordedForeignKeychainReads == 0) + #expect(Self.cliInvocations(at: cliInvocationLog).isEmpty) + } + + private func makePersistedOAuthContext( + suite: String, + environment: [String: String], + sourceMode: ProviderSourceMode = .oauth) throws -> ProviderFetchContext + { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .claude, + source: sourceMode, + cookieHeader: "sessionKey=synthetic-web-session", + cookieSource: .manual), + ]) + let settings = testSettingsStore(suiteName: suite, config: config) + + #expect(settings.providerConfig(for: .claude)?.source == sourceMode) + #expect(settings.claudeUsageDataSource.rawValue == sourceMode.rawValue) + #expect(settings.claudeSettingsSnapshot(tokenOverride: nil).usageDataSource.rawValue == sourceMode.rawValue) + + let browserDetection = BrowserDetection(cacheTTL: 0) + let specs = ProviderRegistry.shared.specs( + settings: settings, + metadata: ProviderRegistry.shared.metadata, + codexFetcher: UsageFetcher(environment: environment), + claudeFetcher: UnexpectedClaudeFetcher(), + browserDetection: browserDetection, + environmentBase: environment) + return try #require(specs[.claude]).makeFetchContext() + } + + private nonisolated static func withIsolatedCredentialState( + credentialsURLOverride: URL?, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + let service = "com.steipete.codexbar.oauth-upgrade-tests.\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting( + credentialsURLOverride) + { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await operation() + } + } + } + } + } + } + + private nonisolated static func withForeignKeychainTripwires( + calls: CallLog, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + let foreignCredentials = Self.makeCredentialsData(accessToken: "foreign-keychain-token") + return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: foreignCredentials, + fingerprint: .init(modifiedAt: 1, createdAt: 1, persistentRefHash: "foreign-ref")) + { + try await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + calls.recordForeignKeychainRead() + return foreignCredentials + }) { + try await ClaudeOAuthCredentialsStore.withInteractiveClaudeKeychainReadOverridesForTesting( + read: { + calls.recordForeignKeychainRead() + return foreignCredentials + }, + operation: { + #expect( + ClaudeOAuthCredentialsStore + .directClaudeCodeKeychainAccessAllowedForTesting == false) + return try await operation() + }) + } + } + } + } + + private nonisolated static func withWebTripwires( + calls: CallLog, + operation: @escaping @Sendable () async throws -> T) async rethrows -> T + { + let availability: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in + calls.recordWebCall("availability") + return true + } + let loader: ClaudeWebFetchStrategy.UsageLoader = { _ in + calls.recordWebCall("fetch") + throw ClaudeUsageError.parseFailed("unexpected web fetch") + } + return try await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(availability) { + try await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(loader) { + try await operation() + } + } + } + + private nonisolated static func makeCredentialsData( + accessToken: String, + expiresAt: Date = Date(timeIntervalSinceNow: 3600)) -> Data + { + let expiresAt = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(expiresAt), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """.utf8)) + } + + private nonisolated static func makeCLIUsageSnapshot() -> ClaudeStatusSnapshot { + ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "synthetic@example.invalid", + accountOrganization: "Synthetic Org", + loginMethod: "claude.ai", + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Friday", + opusResetDescription: "Resets Friday", + rawText: "synthetic") + } + + private static func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-oauth-upgrade-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func makeFakeClaudeCLI( + in directory: URL, + loggedIn: Bool = true) throws -> (executable: URL, invocationLog: URL) + { + let executable = directory.appendingPathComponent("claude") + let invocationLog = directory.appendingPathComponent("claude-invocations.log") + let loggedInJSON = loggedIn ? "true" : "false" + try Data(""" + #!/bin/sh + printf '%s\\n' "$*" >> "\(invocationLog.path)" + if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + printf '%s\\n' '{"loggedIn":\(loggedInJSON),"authMethod":"claude.ai"}' + exit 0 + fi + exit 88 + """.utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + return (executable, invocationLog) + } + + private static func cliInvocations(at url: URL) -> String { + (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } +} diff --git a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift index 722ce155b6..b77ca77faf 100644 --- a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift +++ b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift @@ -139,6 +139,43 @@ struct ClaudeProbeWorkingDirectoryTests { #expect(!FileManager.default.fileExists(atPath: probeSession.path)) } + @Test + func `cleanup resolves one literal relative profile root like Claude`() throws { + let probeDirectory = try Self.makeTemporaryDirectory() + let homeDirectory = try Self.makeTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: probeDirectory) + try? FileManager.default.removeItem(at: homeDirectory) + } + + let relativeProfile = "profiles/team,a" + let selectedRoot = probeDirectory.appendingPathComponent(relativeProfile, isDirectory: true) + let defaultRoot = homeDirectory.appendingPathComponent(".claude", isDirectory: true) + let projectDirectoryName = ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName( + for: probeDirectory) + let selectedProject = selectedRoot + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(projectDirectoryName, isDirectory: true) + let defaultProject = defaultRoot + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(projectDirectoryName, isDirectory: true) + try FileManager.default.createDirectory(at: selectedProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: defaultProject, withIntermediateDirectories: true) + + let selectedTranscript = selectedProject.appendingPathComponent("selected.jsonl") + let defaultTranscript = defaultProject.appendingPathComponent("default.jsonl") + try Data("{}\n".utf8).write(to: selectedTranscript) + try Data("{}\n".utf8).write(to: defaultTranscript) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": relativeProfile, "HOME": homeDirectory.path]) + + #expect(removed.map(\.lastPathComponent) == ["selected.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: selectedTranscript.path)) + #expect(FileManager.default.fileExists(atPath: defaultTranscript.path)) + } + private static func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-probe-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ClaudeResilienceTests.swift b/Tests/CodexBarTests/ClaudeResilienceTests.swift index 83a817ffc9..9b9fccee4f 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -651,7 +651,7 @@ struct ClaudeResilienceTests { } @Test - func `keychain change clears prior Claude snapshot for transient failure`() async throws { + func `keychain change does not affect normal Claude refresh`() async throws { try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -749,11 +749,13 @@ struct ClaudeResilienceTests { let result = await MainActor.run { ( hasSnapshot: store.snapshot(for: .claude) != nil, - hasError: store.error(for: .claude) != nil) + hasError: store.error(for: .claude) != nil, + storedFingerprint: fingerprintStore.fingerprint) } - #expect(!result.hasSnapshot) - #expect(result.hasError) + #expect(result.hasSnapshot) + #expect(!result.hasError) + #expect(result.storedFingerprint == storedFingerprint) } } } @@ -766,7 +768,7 @@ struct ClaudeResilienceTests { } @Test - func `keychain removal clears prior Claude snapshot for transient failure`() async throws { + func `keychain removal does not affect normal Claude refresh`() async throws { try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -864,9 +866,9 @@ struct ClaudeResilienceTests { storedFingerprint: fingerprintStore.fingerprint) } - #expect(!result.hasSnapshot) - #expect(result.hasError) - #expect(result.storedFingerprint == nil) + #expect(result.hasSnapshot) + #expect(!result.hasError) + #expect(result.storedFingerprint == storedFingerprint) } } } @@ -981,7 +983,7 @@ extension ClaudeResilienceTests { } @Test - func `keychain change clears once then preserves later reset backfill`() async throws { + func `keychain change does not affect reset backfill`() async throws { try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -1077,8 +1079,8 @@ extension ClaudeResilienceTests { let firstReset = await MainActor.run { store.snapshot(for: .claude)?.primary?.resetsAt } - #expect(firstReset == nil) - #expect(fingerprintStore.fingerprint == currentFingerprint) + #expect(firstReset == resetDate) + #expect(fingerprintStore.fingerprint == storedFingerprint) await MainActor.run { let seed = UsageSnapshot( @@ -1099,6 +1101,7 @@ extension ClaudeResilienceTests { } #expect(secondReset == resetDate) + #expect(fingerprintStore.fingerprint == storedFingerprint) } } } diff --git a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift index c06851e1fb..05b58dd946 100644 --- a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift +++ b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift @@ -57,6 +57,52 @@ struct ClaudeSourcePlannerTests { #expect(plan.compatibilityStrategy == ClaudeUsageStrategy(dataSource: .cli, useWebExtras: true)) } + @Test + func `app explicit OAuth selects owner mediated CLI when direct credentials are unavailable`() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .oauth, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: false)) + + #expect(plan.orderedSteps.map(\.dataSource) == [.oauth, .cli]) + #expect(plan.orderedSteps.map(\.inclusionReason) == [ + .explicitSourceSelection, + .explicitOAuthOwnerCLIFallback, + ]) + #expect(plan.compatibilityStrategy == ClaudeUsageStrategy(dataSource: .cli, useWebExtras: false)) + } + + @Test + func `app explicit OAuth selects direct OAuth when credentials are available`() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .app, + selectedDataSource: .oauth, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: true)) + + #expect(plan.orderedSteps.map(\.dataSource) == [.oauth, .cli]) + #expect(plan.compatibilityStrategy == ClaudeUsageStrategy(dataSource: .oauth, useWebExtras: false)) + } + + @Test + func `CLI explicit OAuth remains terminal`() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .cli, + selectedDataSource: .oauth, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: false)) + + #expect(plan.orderedSteps.map(\.dataSource) == [.oauth]) + #expect(plan.orderedSteps.map(\.inclusionReason) == [.explicitSourceSelection]) + } + @Test func `app auto CLI fallback reports web extras like runtime`() { let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( diff --git a/Tests/CodexBarTests/ClaudeTokenAccountRoutingTests.swift b/Tests/CodexBarTests/ClaudeTokenAccountRoutingTests.swift new file mode 100644 index 0000000000..a1a3d897e3 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeTokenAccountRoutingTests.swift @@ -0,0 +1,182 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +@Suite(.serialized) +@MainActor +struct ClaudeTokenAccountRoutingTests { + @Test(arguments: [ + ProviderSourceMode.auto, + ProviderSourceMode.api, + ProviderSourceMode.web, + ProviderSourceMode.cli, + ProviderSourceMode.oauth, + ]) + func `CLI web account override wins over every global source`( + sourceMode: ProviderSourceMode) async throws + { + let account = ProviderTokenAccount( + id: UUID(), + label: "Web", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: sourceMode, + provider: .claude, + account: account) + let settings = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let env = tokenContext.environment(base: [:], provider: .claude, account: account) + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: effectiveSourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: account.id) + + let strategies = await ProviderDescriptorRegistry.descriptor(for: .claude) + .fetchPlan.pipeline.resolveStrategies(context) + + #expect(effectiveSourceMode == .web) + #expect(settings.claude?.usageDataSource == .web) + #expect(strategies.map(\.id) == ["claude.web"]) + } + + @Test + func `OAuth account override wins over global source and active account`() throws { + let settings = testSettingsStore(suiteName: "ClaudeTokenAccountRoutingTests-multi-account") + settings.claudeUsageDataSource = .api + settings.addTokenAccount(provider: .claude, label: "First", token: "Bearer sk-ant-oat-first-token") + settings.addTokenAccount(provider: .claude, label: "Second", token: "Bearer sk-ant-oat-second-token") + let first = try #require(settings.tokenAccounts(for: .claude).first) + let active = try #require(settings.selectedTokenAccount(for: .claude)) + let accountOverride = TokenAccountOverride(provider: .claude, account: first) + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .claude, + settings: settings, + tokenOverride: accountOverride) + let snapshot = ProviderRegistry.makeSettingsSnapshot( + settings: settings, + tokenOverride: accountOverride) + + #expect(active.label == "Second") + #expect(settings.selectedTokenAccount(for: .claude)?.id == active.id) + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-first-token") + #expect(snapshot.claude?.usageDataSource == .oauth) + #expect(snapshot.claude?.cookieSource == .off) + } + + @Test(arguments: [ + ProviderSourceMode.auto, + ProviderSourceMode.api, + ProviderSourceMode.web, + ProviderSourceMode.cli, + ProviderSourceMode.oauth, + ]) + func `selected web account overrides every global source`(sourceMode: ProviderSourceMode) async throws { + let settings = testSettingsStore(suiteName: "ClaudeTokenAccountRoutingTests-web-\(sourceMode.rawValue)") + settings.claudeUsageDataSource = .api + settings.addTokenAccount( + provider: .claude, + label: "Web session", + token: "sessionKey=sk-ant-selected-session-token") + let account = try #require(settings.selectedTokenAccount(for: .claude)) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .claude, + settings: settings, + tokenOverride: nil) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: snapshot, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: account.id) + + let strategies = await ProviderDescriptorRegistry.descriptor(for: .claude) + .fetchPlan.pipeline.resolveStrategies(context) + + #expect(snapshot.claude?.usageDataSource == .web) + #expect(snapshot.claude?.cookieSource == .manual) + #expect(snapshot.claude?.manualCookieHeader == "sessionKey=sk-ant-selected-session-token") + #expect(strategies.map(\.id) == ["claude.web"]) + let strategy = try #require(strategies.first) + #expect(await strategy.isAvailable(context)) + } + + @Test(arguments: [ + ProviderSourceMode.auto, + ProviderSourceMode.api, + ProviderSourceMode.web, + ProviderSourceMode.cli, + ProviderSourceMode.oauth, + ]) + func `malformed selected account fails closed under every global source`( + sourceMode: ProviderSourceMode) async throws + { + let settings = testSettingsStore(suiteName: "ClaudeTokenAccountRoutingTests-invalid-\(sourceMode.rawValue)") + settings.addTokenAccount(provider: .claude, label: "Invalid", token: "Cookie:") + let account = try #require(settings.selectedTokenAccount(for: .claude)) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .claude, + settings: settings, + tokenOverride: nil) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: snapshot, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: account.id) + + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let outcome = await descriptor.fetchOutcome(context: context) + + #expect(strategies.isEmpty) + #expect(outcome.attempts.isEmpty) + guard case let .failure(error as ProviderFetchError) = outcome.result, + case let .noAvailableStrategy(provider) = error + else { + Issue.record("Expected selected malformed account to fail with noAvailableStrategy") + return + } + #expect(provider == .claude) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 1f23fb2eb0..21cb1c7a41 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -260,10 +260,8 @@ struct ClaudeUsageTests { #expect(await delegatedCounter.current() == 1) #expect(snapshot.primary.usedPercent == 7) - // User-initiated repair: if the delegated refresh couldn't sync silently, we may allow an interactive prompt - // on the retry to help recovery. #expect(flags.allowKeychainPromptFlags.count == 2) - #expect(flags.allowKeychainPromptFlags[1] == true) + #expect(flags.allowKeychainPromptFlags == [false, false]) } @Test @@ -978,6 +976,10 @@ struct ClaudeAutoFetcherCharacterizationTests { let script = """ #!/bin/sh LOG_FILE='\(logURL.path)' + if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then + printf '%s\n' '{"loggedIn":true}' + exit 0 + fi while IFS= read -r line; do case "$line" in *"/usage"*) @@ -1060,7 +1062,7 @@ struct ClaudeAutoFetcherCharacterizationTests { } @Test - func `auto prefers OAuth even when web and CLI appear available`() async throws { + func `app Auto prefers safe OAuth before CLI and web`() async throws { let usageResponse = try Self.makeOAuthUsageResponse() let cliLogURL = FileManager.default.temporaryDirectory .appendingPathComponent("claude-auto-cli-log-\(UUID().uuidString).txt") @@ -1103,6 +1105,52 @@ struct ClaudeAutoFetcherCharacterizationTests { } } + @Test(arguments: [false, true]) + func `app Auto cancellation never advances beyond OAuth`(wrappedTransportCancellation: Bool) async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auto-cancel-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL) + let webRequests = RequestLog() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [ + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + ], + runtime: .app, + dataSource: .auto, + manualCookieHeader: "sessionKey=sk-ant-session-token") + + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + try await self.withClaudeWebStub(handler: { request in + webRequests.append(request.url?.path ?? "") + let url = try #require(request.url) + return Self.makeJSONResponse(url: url, body: "{}") + }, operation: { + let cancelledOAuth: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + if wrappedTransportCancellation { + throw ClaudeOAuthFetchError.networkError(URLError(.cancelled)) + } + throw CancellationError() + } + do { + _ = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(cancelledOAuth) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + Issue.record("Cancelled Auto OAuth unexpectedly succeeded") + } catch { + #expect(ClaudeOAuthFetchError.isCancellation(error)) + } + + #expect(log.contents().isEmpty) + #expect(webRequests.current().isEmpty) + }) + } + } + } + @Test func `app runtime auto prefers CLI before web when OAuth unavailable`() async throws { let cliLogURL = FileManager.default.temporaryDirectory @@ -1250,7 +1298,7 @@ struct ClaudeAutoFetcherCharacterizationTests { } @Test - func `app runtime auto fails deterministically when planner has no executable steps`() async { + func `app runtime auto surfaces OAuth absence when no fallback source is available`() async { let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), environment: ["CLAUDE_CLI_PATH": "/definitely/missing/claude"], @@ -1263,8 +1311,11 @@ struct ClaudeAutoFetcherCharacterizationTests { do { _ = try await fetcher.loadLatestUsage(model: "sonnet") Issue.record("Expected app auto no-source fetch to fail.") - } catch let error as ClaudeUsageError { - #expect(error.localizedDescription.contains("Claude planner produced no executable steps.")) + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Unexpected OAuth failure: \(error)") + return + } } catch { Issue.record("Unexpected error: \(error)") } @@ -1446,7 +1497,7 @@ extension ClaudeUsageTests { } await #expect(throws: ClaudeUsageError.self) { - try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + _ = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( .securityCLIExperimental, operation: { try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { @@ -1474,7 +1525,7 @@ extension ClaudeUsageTests { } @Test - func `oauth load experimental background fallback blocked propagates O auth failure`() async throws { + func `oauth load experimental background preserves typed credential absence`() async throws { final class FlagBox: @unchecked Sendable { var respectPromptCooldownFlags: [Bool] = [] } @@ -1494,8 +1545,8 @@ extension ClaudeUsageTests { throw ClaudeOAuthCredentialsError.notFound } - await #expect(throws: ClaudeUsageError.self) { - try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + do { + _ = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( .securityCLIExperimental, operation: { try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { @@ -1506,6 +1557,14 @@ extension ClaudeUsageTests { } } }) + Issue.record("Expected typed OAuth credential absence.") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Unexpected OAuth failure: \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") } #expect(flags.respectPromptCooldownFlags == [true]) } diff --git a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift index 2972e61f0e..bc22c0fa20 100644 --- a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift +++ b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift @@ -91,6 +91,12 @@ struct ClaudeWebFetchDeadlineTests { let planningProbe = ClaudeWebPlanningAvailabilityProbe() let cliPath = try Self.makeLoggedInClaudeCLI() defer { try? FileManager.default.removeItem(atPath: cliPath) } + let profileRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-web-deadline-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: profileRoot) } + try Data(#"{"oauthAccount":{"accountUuid":"deadline-account"}}"#.utf8) + .write(to: profileRoot.appendingPathComponent(".config.json"), options: .atomic) let context = Self.makeContext( runtime: .app, sourceMode: .auto, @@ -98,22 +104,20 @@ struct ClaudeWebFetchDeadlineTests { cookieSource: .auto, env: [ "CLAUDE_CLI_PATH": cliPath, - ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + "CLAUDE_CONFIG_DIR": profileRoot.path, ]) let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in planningProbe.stallAndReportUnavailable() } - let oauthLoadOverride: (@Sendable ( - [String: String], - Bool, - Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in - throw ClaudeUsageError.oauthFailed("stub OAuth failure") - } + let oauthLoadOverride: @Sendable ([String: String], Bool, Bool) async throws + -> ClaudeOAuthCredentials = { _, _, _ in + throw ClaudeOAuthCredentialsError.notFound + } let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in Self.makeClaudeStatus() } let outcome = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: cliPath) + ClaudeCLIBackgroundAvailability.establish(binary: cliPath, environment: context.env) return await KeychainAccessGate.withTaskOverrideForTesting(false) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( @@ -146,19 +150,10 @@ struct ClaudeWebFetchDeadlineTests { sourceMode: .auto, webTimeout: 60, cookieSource: .auto, - env: [ - "CLAUDE_CLI_PATH": "/usr/bin/true", - ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", - ]) + env: ["CLAUDE_CLI_PATH": "/usr/bin/true"]) let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in planningProbe.stallAndReportUnavailable() } - let oauthLoadOverride: (@Sendable ( - [String: String], - Bool, - Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in - throw ClaudeUsageError.oauthFailed("stub OAuth failure") - } let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in throw ClaudeUsageError.parseFailed("stub CLI failure") } @@ -170,12 +165,10 @@ struct ClaudeWebFetchDeadlineTests { let fetchTask = Task { await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(availabilityOverride) { await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { - await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { - await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { - await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( - context: context, - provider: .claude) - } + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) } } } diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift index 3db56c7272..8cdce3edb2 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift @@ -158,4 +158,41 @@ struct CostUsageScannerClaudeDesktopTests { #expect(report.data[0].outputTokens == 4) #expect(report.data[0].totalTokens == 28) } + + @Test + func `Claude config root is one relative literal and always owns a projects child`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CostUsageScanner-ClaudePaths-\(UUID().uuidString)", isDirectory: true) + let workingDirectory = root.appendingPathComponent("probe", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let literal = " first, second " + let roots = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: root.appendingPathComponent("cache")), + environment: [ClaudeConfigPaths.configDirectoryEnvironmentKey: literal], + homeDirectory: root.appendingPathComponent("home"), + workingDirectory: workingDirectory) + #expect(roots == [workingDirectory + .appendingPathComponent(literal, isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + .standardizedFileURL]) + + let projectsNamedRoot = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: root.appendingPathComponent("cache")), + environment: [ClaudeConfigPaths.configDirectoryEnvironmentKey: "projects"], + homeDirectory: root.appendingPathComponent("home"), + workingDirectory: workingDirectory) + #expect(projectsNamedRoot == [workingDirectory + .appendingPathComponent("projects/projects", isDirectory: true) + .standardizedFileURL]) + + let relativeHomeRoots = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: root.appendingPathComponent("cache")), + environment: ["HOME": "relative-home", ClaudeConfigPaths.configDirectoryEnvironmentKey: ""], + homeDirectory: root.appendingPathComponent("ignored-home"), + workingDirectory: workingDirectory) + #expect(relativeHomeRoots.contains(workingDirectory + .appendingPathComponent("relative-home/.claude/projects", isDirectory: true) + .standardizedFileURL)) + } } diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 382543269a..cd45756c4e 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -167,6 +167,78 @@ struct KeychainCacheStoreTests { } #if os(macOS) + @Test + func `unsafe cache ACL is unusable for credential planning`() { + let service = "cache-preflight-\(UUID().uuidString)" + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let observed = LockIsolated<(String, String?)?>(nil) + + let result: KeychainCacheStore.LoadResult = KeychainCacheStore.withServiceOverrideForTesting( + service) + { + KeychainCacheStore.withRealKeychainPathForTesting { + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { candidateService, account in + observed.setValue((candidateService, account)) + return .interactionRequired + } operation: { + KeychainCacheStore.load(key: key, as: TestEntry.self) + } + } + } + } + + #expect(observed.value?.0 == service) + #expect(observed.value?.1 == key.account) + switch result { + case .missing: + break + case .found, .invalid, .temporarilyUnavailable: + Issue.record("Expected an unsafe cache item to behave as missing") + } + } + + @Test + func `cache secret read stops when attributes preflight finds no item`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let preflight: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in .notFound } + let result: KeychainCacheStore.LoadResult = KeychainCacheStore.withRealKeychainPathForTesting { + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(preflight, operation: { + KeychainCacheStore.load(key: key, as: TestEntry.self) + }) + } + } + + switch result { + case .missing: + break + case .found, .temporarilyUnavailable, .invalid: + Issue.record("Expected a missing preflight item to skip the secret-data query") + } + } + + @Test + func `cache store and clear stop when decrypt ACL requires interaction`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "blocked", storedAt: Date(timeIntervalSince1970: 0)) + let recorder = KeychainCacheStore.OperationRecorder() + let preflight: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in .interactionRequired } + + KeychainCacheStore.withRealKeychainPathForTesting { + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.withOperationRecorderForTesting(recorder) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(preflight) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry) == false) + #expect(KeychainCacheStore.clearResult(key: key) == .failed) + } + } + } + } + + #expect(recorder.operations == [.store, .clear]) + } + @Test func `interaction not allowed is treated as temporarily unavailable`() { let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) @@ -326,5 +398,19 @@ struct KeychainCacheStoreTests { executable.path, ]) } + + @Test + func `cache preflight inspects only the invoking executable`() { + let root = URL(fileURLWithPath: "/Applications/CodexBar.app") + let executable = root.appendingPathComponent("Contents/MacOS/CodexBar") + let helper = root.appendingPathComponent("Contents/Helpers/CodexBarCLI") + + let currentPaths = KeychainCacheStore.invokingApplicationPathsForCacheAccess( + executableURL: executable, + fileExists: { $0 == executable.path }) + + #expect(currentPaths == [executable.path]) + #expect(!currentPaths.contains(helper.path)) + } #endif } diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 436d9ea5cb..cac8d78cea 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -45,10 +45,51 @@ struct KeychainNoUIQueryTests { #expect(query[kSecReturnData as String] == nil) #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect(query[kSecReturnRef as String] as? Bool == true) #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) } + @Test + func `decrypt ACL requires successful code signature validation without a prompt selector`() { + #expect(KeychainAccessPreflight.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: [true], + promptSelector: [])) + #expect(!KeychainAccessPreflight.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: [false], + promptSelector: [])) + #expect(!KeychainAccessPreflight.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: [], + promptSelector: [])) + #expect(KeychainAccessPreflight.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: nil, + promptSelector: [])) + #expect(!KeychainAccessPreflight.decryptACLAllowsCurrentProcess( + trustedApplicationValidationResults: [true], + promptSelector: .init(rawValue: 1))) + } + + @Test + func `trusted application validation rejects a replacement binary at the same path`() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appendingPathComponent("codexbar-keychain-acl-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + + let candidate = directory.appendingPathComponent("candidate") + try fileManager.copyItem(atPath: CommandLine.arguments[0], toPath: candidate.path) + + let (createStatus, trustedApplication) = KeychainCacheStore.createTrustedApplication(path: candidate.path) + #expect(createStatus == errSecSuccess) + let application = try #require(trustedApplication) + #expect(KeychainAccessPreflight.trustedApplication(application, validatesExecutableAt: candidate.path)) + + try fileManager.removeItem(at: candidate) + try fileManager.copyItem(atPath: "/usr/bin/true", toPath: candidate.path) + #expect(!KeychainAccessPreflight.trustedApplication(application, validatesExecutableAt: candidate.path)) + } + @Test func `preflight query executes without invalid UI policy`() { let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 3aa84a5b23..fe38f6ea71 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -328,7 +328,7 @@ struct SettingsStoreCoverageTests { let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) - #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.usageDataSource == .oauth) #expect(snapshot.cookieSource == .off) #expect(snapshot.manualCookieHeader?.isEmpty == true) } @@ -340,7 +340,7 @@ struct SettingsStoreCoverageTests { let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) - #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.usageDataSource == .web) #expect(snapshot.cookieSource == .manual) #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") } diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index dc3ea5e86e..acb4353446 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -98,6 +98,52 @@ struct TTYIntegrationTests { #expect(!commands.contains("/status")) } + @Test + func `claude pty keepalive relaunches when account or launch environment changes`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYAccountScope-\(UUID().uuidString)", isDirectory: true) + let configRoot = root.appendingPathComponent("profile", isDirectory: true) + let launchLog = root.appendingPathComponent("launches.log") + try FileManager.default.createDirectory(at: configRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let configURL = configRoot.appendingPathComponent(".config.json") + try Self.writeClaudeAccount("account-a", to: configURL) + let cli = try Self.makeAccountScopedClaudeCLI(in: root, launchLog: launchLog) + let environment = [ + "CLAUDE_CONFIG_DIR": configRoot.path, + "HOME": configRoot.path, + "AWS_PROFILE": "profile-a", + ] + let probe = ClaudeStatusProbe( + claudeBinary: cli.path, + timeout: 5, + keepCLISessionsAlive: true, + environment: environment) + + try await ClaudeCLISession.withIsolatedSessionForTesting { + _ = try await probe.fetch() + _ = try await probe.fetch() + try Self.writeClaudeAccount("account-b", to: configURL) + _ = try await probe.fetch() + var changedEnvironment = environment + changedEnvironment["AWS_PROFILE"] = "profile-b" + let changedEnvironmentProbe = ClaudeStatusProbe( + claudeBinary: cli.path, + timeout: 5, + keepCLISessionsAlive: true, + environment: changedEnvironment) + _ = try await changedEnvironmentProbe.fetch() + } + + let launches = try String(contentsOf: launchLog, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map(String.init) + #expect(launches.count == 3) + #expect(launches.first?.hasSuffix(":account-a:profile-a") == true) + #expect(launches.dropFirst().first?.hasSuffix(":account-b:profile-a") == true) + #expect(launches.last?.hasSuffix(":account-b:profile-b") == true) + } + private static func makeSlowUsageClaudeCLI() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) @@ -149,4 +195,34 @@ struct TTYIntegrationTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) return url } + + private static func writeClaudeAccount(_ account: String, to url: URL) throws { + try Data("{\"oauthAccount\":{\"accountUuid\":\"\(account)\"}}".utf8).write(to: url, options: .atomic) + } + + private static func makeAccountScopedClaudeCLI(in directory: URL, launchLog: URL) throws -> URL { + let url = directory.appendingPathComponent("claude") + let script = """ + #!/bin/sh + ACCOUNT=$(sed -n 's/.*"accountUuid":"\\([^"]*\\)".*/\\1/p' "$CLAUDE_CONFIG_DIR/.config.json") + printf 'launch:%s:%s:%s\\n' "$$" "$ACCOUNT" "$AWS_PROFILE" >> '\(launchLog.path)' + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'Settings Status Config Usage' + printf '%s\\n' 'Current session' + printf '%s\\n' '93% left' + printf '%s\\n' 'Current week (all models)' + printf '%s\\n' '79% left' + ;; + *"/status"*) + printf 'Account: %s@example.com\\n' "$ACCOUNT" + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 56e42dca25..894ba49896 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -770,7 +770,7 @@ struct TokenAccountEnvironmentPrecedenceTests { let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) let claudeSettings = try #require(snapshot.claude) - #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.usageDataSource == .web) #expect(claudeSettings.cookieSource == .manual) #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift index f321f45b33..f65abb0da4 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift @@ -126,6 +126,33 @@ struct UsageStorePlanUtilizationClaudeIdentityBoundaryTests { .entries.map(\.usedPercent) == [90]) } + @MainActor + @Test + func `V1 account bindings are discarded so owner mediated history continues`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "a", count: 64) + let legacyKey = "ClaudeOAuthHistoryOwnerAccountUuidMapV1" + let legacyMap = [owner: "obsolete-v1-identity"] + let legacyData = try JSONEncoder().encode(legacyMap) + store.settings.userDefaults.set(legacyData, forKey: legacyKey) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 60), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialUnavailable: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + #expect(store.settings.userDefaults.object(forKey: legacyKey) == nil) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [60]) + } + @MainActor @Test func `absent keychain still quarantines an owner bound to another account`() async {