Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1e0ccc4
Stop reading Claude-owned credentials
ProspectOre Jul 28, 2026
b2c0957
Route Claude CLI sessions by profile
ProspectOre Jul 28, 2026
fea8daf
Scope Claude probe cleanup by profile
ProspectOre Jul 28, 2026
d58d35e
Serialize Claude CLI profile captures
ProspectOre Jul 28, 2026
ca0d12f
Resolve Claude cleanup in the active profile
ProspectOre Jul 28, 2026
4d83c98
Bind Claude cache ownership to credentials
ProspectOre Jul 28, 2026
7a7a489
Preserve selected Claude profile MCP routing
ProspectOre Jul 29, 2026
7c14e35
Split Claude profile routing regression
ProspectOre Jul 29, 2026
129a8e8
Integrate Claude ownership routing with profile cache
ProspectOre Jul 29, 2026
a9296a2
Gate background Claude OAuth recovery
ProspectOre Jul 29, 2026
cfba3f9
Scope Claude background availability by profile
ProspectOre Jul 29, 2026
78cce78
Isolate Claude background establishment test
ProspectOre Jul 29, 2026
63c0ae7
Fix Claude background marker revocation race
ProspectOre Jul 29, 2026
6226f50
Reject stale Claude OAuth after account switch
ProspectOre Jul 29, 2026
1385fda
Quarantine rejected Claude OAuth files
ProspectOre Jul 29, 2026
6ca4279
Scope Claude refresh failures by profile
ProspectOre Jul 30, 2026
53ee756
Scope Claude account identity by profile
ProspectOre Jul 30, 2026
0173d7c
Ignore global Keychain in Claude failure gate
ProspectOre Jul 30, 2026
627fd5e
Route expired Claude caches through owner CLI
ProspectOre Jul 30, 2026
d8847b9
Stabilize Claude account identity path
ProspectOre Jul 30, 2026
add9e3e
Restore prompt-safe cache ACL preflight
ProspectOre Jul 30, 2026
d24a053
Exercise real app lifecycle in Auto verifier
ProspectOre Jul 30, 2026
2e7558f
Scope delegated refresh joins by Claude profile
ProspectOre Jul 30, 2026
cafa839
Isolate Claude usage probe from ambient MCP servers
ProspectOre Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
35 changes: 21 additions & 14 deletions Sources/CodexBar/SettingsStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:))
Expand Down
277 changes: 277 additions & 0 deletions Sources/CodexBar/UsageStore+ClaudeActiveAccountIdentity.swift
Original file line number Diff line number Diff line change
@@ -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 root>/.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<String>
{
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<T>(
_ uuid: String?,
_ body: () async throws -> T) async rethrows -> T
{
try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue(.value(uuid)) {
try await body()
}
}

static func withActiveClaudeAccountUuidResolverForTesting<T>(
_ 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)
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/UsageStore+CodexResetCredits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading