From 561073357e65ac35bedb8b3b464be7980319e585 Mon Sep 17 00:00:00 2001 From: Steven Coaila Date: Thu, 17 Sep 2026 23:55:52 -0500 Subject: [PATCH] fix(claude): run child CLIs in a private working directory and scope data by account Recovery, codex app-server and TokenGaugeCapture now run inside ~/Library/Application Support/TokenGauge/recovery (empty, 0700) instead of the home directory, so Claude Code no longer indexes protected folders and macOS stops attributing iCloud, Documents and Desktop prompts to TokenGauge. Account identity is read from ~/.claude.json on every refresh; a changed account invalidates the cached token with one Keychain re-read. Snapshots, status-line captures, quota samples and pace samples carry a SHA-256 fingerprint of the account uuid so a foreign snapshot is never shown as live and pace continuity stays per account. The Claude card header shows the active account label. --- AGENTS.md | 6 +- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Services/ClaudeAccountUsageClient.swift | 17 ++- .../Services/ClaudeRecoveryProcess.swift | 10 +- .../Services/ClaudeSessionRecovery.swift | 3 +- .../Services/ClaudeUsageClient.swift | 57 +++++++--- .../Services/CodexAppServerClient.swift | 11 +- .../Services/EffortHistoryClient.swift | 5 +- .../Services/HistoryDashboardModel.swift | 12 +- .../TokenGaugeApp/Services/UsageStore.swift | 29 ++++- Sources/TokenGaugeApp/Views/PopoverView.swift | 4 +- .../TokenGaugeApp/Views/ProviderCard.swift | 12 ++ Sources/TokenGaugeCapture/main.swift | 5 +- .../ClaudeAccountIdentity.swift | 106 ++++++++++++++++++ .../ClaudeAccountUsageParser.swift | 8 +- Sources/TokenGaugeCore/ClaudeOAuthToken.swift | 28 ++++- .../TokenGaugeCore/ClaudeUsageParser.swift | 17 ++- .../TokenGaugeCore/UsageHistoryStore.swift | 73 ++++++++---- Sources/TokenGaugeCore/UsagePaths.swift | 10 ++ .../ClaudeAccountHeaderRenderingTests.swift | 71 ++++++++++++ .../ClaudeAccountScopeTests.swift | 92 +++++++++++++++ .../ClaudeHistoryProcessTests.swift | 15 ++- .../ClaudeRecoveryLiveTests.swift | 3 +- .../ClaudeRecoveryProcessTests.swift | 51 ++++++++- .../PortableRuntimeTests.swift | 8 +- .../ClaudeAccountIdentityTests.swift | 94 ++++++++++++++++ .../ClaudeOAuthTokenTests.swift | 44 ++++++++ .../HistoryPaceStoreTests.swift | 30 ++++- .../QuotaContinuityTests.swift | 47 +++++++- 30 files changed, 788 insertions(+), 82 deletions(-) create mode 100644 Sources/TokenGaugeCore/ClaudeAccountIdentity.swift create mode 100644 Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift create mode 100644 Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift create mode 100644 Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift diff --git a/AGENTS.md b/AGENTS.md index e62361a..6ffbca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ - Claude quota data comes from `GET https://api.anthropic.com/api/oauth/usage`, the endpoint `/usage` itself calls, with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. Parse the `limits` array (`session`, `weekly_all`, `weekly_scoped` with `scope.model.display_name`), not the legacy top-level buckets: the model-scoped weekly limit exists only there. - **Never refresh, rotate, or write the OAuth credential.** Read the Keychain item (`Claude Code-credentials` / the login name) through `/usr/bin/security find-generic-password -w` as a subprocess, never `SecItemCopyMatching` from the app: Claude Code recreates the item on every token refresh, which drops the ACL grant Steven gave TokenGauge, so the direct read re-prompted him after every refresh (2026-08-26/27) even with "Always Allow"; `security` is the item creator and reads silently. Keep the token only in process memory (`ClaudeOAuthTokenReader` cache), read once per launch and re-read only after it expires. Claude Code owns that credential and refreshes it; a refresh from here would rotate the refresh token and sign Steven out. An expired token is a distinct recoverable state. With persisted opt-in, TokenGauge may briefly start the official Claude CLI in safe mode without a prompt, then re-read the credential and usage endpoint; Claude Code alone renews it. Bound the owned process group, discard terminal output, and persist retry backoff. Missing credentials, 401 revocation, and 403 must never trigger that startup. - Call the usage endpoint through an ephemeral `URLSession` with no URL cache: `URLSession.shared` cached a 401 and replayed it (`cache_hit=true`) on every refresh while the token was valid, so the Fable row vanished (2026-08-26). On a real 401, invalidate the token cache and re-read the Keychain once — Claude Code rotates the token. -- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk. +- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk; the only account data stored is a SHA-256 fingerprint of the account uuid, which scopes the account snapshot and pace continuity. +- The account identity comes from `~/.claude.json` only; `CLAUDE_CONFIG_DIR` is not resolved (the same assumption already made for `~/.claude/projects`), so users who move that directory get no account scoping. +- The status-line capture is tagged with the identity current when `TokenGaugeCapture` writes it, so a Claude Code session that outlives a `claude /login` in another terminal can still attribute its fallback samples to the new account until that session ends. - The status-line `rate_limits` payload stays as the credential-free fallback through `TokenGaugeCapture`. It carries only the session and all-models windows, so the Fable row is absent while the fallback is in use — leave it absent rather than estimating it. - Claude activity reads only timestamps, message IDs, model identifiers, and numeric usage fields from local JSONL transcripts, aggregated into hourly per-model buckets. - The official status line exposes only the `five_hour` and `seven_day` buckets. There is no per-model quota bucket, so per-model figures always come from local transcripts and are labelled as token totals, never as quota. @@ -62,7 +64,7 @@ - Preparing a public release does not authorize publishing it. Repository visibility changes, release publication, release tags and assets require explicit publication authorization; preparation or signing approval alone is insufficient. - UI iteration is M4-only for Steven to review. Broader QA, documentation screenshots, and public release publication require his explicit scope approval. -- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials. +- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials; only the SHA-256 fingerprint of the account uuid may reach disk. - Do not invoke a model request to refresh usage. - Do not scrape provider web pages. - Do not request Accessibility, Automation or Full Disk Access. Claude uses its existing read-only Keychain credential; explain the system access prompt if its ACL requires one. diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings index 5f07e79..7292df0 100644 --- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings @@ -175,3 +175,4 @@ "pace.info_method" = "Calculated over at least 30 observed minutes within the last hour. Resets and long gaps are excluded."; "pace.info_retention" = "When no new usage is recorded, the last active pace stays available with its date. Measurements are stored only on this Mac."; "pace.info_saved" = "Showing the last measurement with usage while waiting for new data."; +"account.current" = "Account: %@"; diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings index 86fa16d..19bae90 100644 --- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings @@ -175,3 +175,4 @@ "pace.info_method" = "Se calcula con al menos 30 minutos observados dentro de la última hora. Los reinicios y los huecos largos se excluyen."; "pace.info_retention" = "Si no se registra consumo nuevo, conservamos el último ritmo activo con su fecha. Las mediciones se guardan solo en este Mac."; "pace.info_saved" = "Mostramos la última medición con consumo mientras llegan datos nuevos."; +"account.current" = "Cuenta: %@"; diff --git a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift index 7ce8550..0ebdb15 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift @@ -32,8 +32,8 @@ struct ClaudeAccountUsageClient: Sendable { return URLSession(configuration: configuration) }() - func fetch(now: Date = Date()) throws -> ClaudeAccountSnapshot { - guard let token = ClaudeOAuthTokenReader.read() else { + func fetch(now: Date = Date(), identity: ClaudeAccountIdentity?) throws -> ClaudeAccountSnapshot { + guard let token = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid) else { throw ClaudeAccountUsageError.authenticationRequired } guard !token.isExpired else { throw ClaudeAccountUsageError.credentialExpired } @@ -49,14 +49,17 @@ struct ClaudeAccountUsageClient: Sendable { outcome = try send(request) } catch ClaudeAccountUsageError.authenticationRequired { ClaudeOAuthTokenReader.invalidate() - guard let fresh = ClaudeOAuthTokenReader.read(), !fresh.isExpired, fresh.value != token.value else { + guard let fresh = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid), !fresh.isExpired, + fresh.value != token.value + else { throw ClaudeAccountUsageError.authenticationRequired } request.setValue("Bearer \(fresh.value)", forHTTPHeaderField: "Authorization") outcome = try send(request) } let windows = try Self.parseWindows(outcome) - let snapshot = ClaudeAccountSnapshot(capturedAt: now, windows: windows) + let snapshot = ClaudeAccountSnapshot( + capturedAt: now, windows: windows, accountFingerprint: identity?.fingerprint) if !windows.isEmpty { try? SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory)) } @@ -76,11 +79,13 @@ struct ClaudeAccountUsageClient: Sendable { } } - func cached() -> ClaudeAccountSnapshot? { - try? SecureMetricStore.read( + func cached(identity: ClaudeAccountIdentity?) -> ClaudeAccountSnapshot? { + let snapshot = try? SecureMetricStore.read( ClaudeAccountSnapshot.self, from: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory) ) + guard let snapshot, snapshot.belongs(to: identity?.fingerprint) else { return nil } + return snapshot } private func send(_ request: URLRequest) throws -> Data { diff --git a/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift b/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift index 3c6d13a..ac6391a 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TokenGaugeCore enum ClaudeRecoveryProcess { static func run( @@ -19,6 +20,13 @@ enum ClaudeRecoveryProcess { shouldContinue: () -> Bool = { true }, recovered: () -> Bool ) -> Bool { guard timeout.isFinite, timeout > 0 else { return false } + let workingDirectory = UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory) + do { + try FileManager.default.createDirectory( + at: workingDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { return false } var master: Int32 = -1 var slave: Int32 = -1 guard openpty(&master, &slave, nil, nil, nil) == 0 else { return false } @@ -35,7 +43,7 @@ enum ClaudeRecoveryProcess { defer { posix_spawnattr_destroy(&attributes) } guard posix_spawnattr_setpgroup(&attributes, 0) == 0, posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0, - posix_spawn_file_actions_addchdir_np(&actions, homeDirectory.path) == 0, + posix_spawn_file_actions_addchdir_np(&actions, workingDirectory.path) == 0, posix_spawn_file_actions_addclose(&actions, master) == 0 else { return false } for descriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { diff --git a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift index a171945..4ddbe99 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift @@ -27,7 +27,8 @@ final class ClaudeSessionRecovery: @unchecked Sendable { shouldContinue: { authorization.isAllowed } ) { ClaudeOAuthTokenReader.invalidate() - return ClaudeOAuthTokenReader.read().map { !$0.isExpired } ?? false + let accountUuid = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory)?.accountUuid + return ClaudeOAuthTokenReader.read(accountUuid: accountUuid).map { !$0.isExpired } ?? false } } } diff --git a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift index 9031709..0ab8390 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift @@ -14,6 +14,19 @@ struct ClaudeUsageResult: Sendable { let snapshot: ProviderUsageSnapshot let access: ClaudeAccessState let lastActivityAt: Date? + let accountFingerprint: String? + let accountLabel: String? + + init( + snapshot: ProviderUsageSnapshot, access: ClaudeAccessState, lastActivityAt: Date?, + accountFingerprint: String? = nil, accountLabel: String? = nil + ) { + self.snapshot = snapshot + self.access = access + self.lastActivityAt = lastActivityAt + self.accountFingerprint = accountFingerprint + self.accountLabel = accountLabel + } } struct ClaudeUsageClient: Sendable { @@ -26,24 +39,32 @@ struct ClaudeUsageClient: Sendable { func fetch( now: Date = Date(), recoveryAuthorization: ClaudeRecoveryAuthorization = ClaudeRecoveryAuthorization() ) throws -> ClaudeUsageResult { + let identity = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory) let buckets = try? fetchModelBuckets() let account = ClaudeAccountUsageClient(homeDirectory: homeDirectory) let outcome = Self.readAccount( - fetch: { try account.fetch(now: Date()) }, + fetch: { try account.fetch(now: Date(), identity: identity) }, recover: { ClaudeSessionRecovery.shared.attempt(homeDirectory: homeDirectory, authorization: recoveryAuthorization) } ) - let capture = try? SecureMetricStore.read( - ClaudeCapturedSnapshot.self, - from: UsagePaths.claudeCapture(homeDirectory: homeDirectory) - ) + let capture = Self.capture( + at: UsagePaths.claudeCapture(homeDirectory: homeDirectory), + accountFingerprint: identity?.fingerprint) return Self.resolve( - account: outcome, cached: account.cached(), capture: capture, modelBuckets: buckets ?? [], now: now, - activityReadSucceeded: buckets != nil + account: outcome, cached: account.cached(identity: identity), capture: capture, + modelBuckets: buckets ?? [], now: now, activityReadSucceeded: buckets != nil, + accountFingerprint: identity?.fingerprint, accountLabel: identity?.label ) } + static func capture(at url: URL, accountFingerprint: String?) -> ClaudeCapturedSnapshot? { + guard let snapshot = try? SecureMetricStore.read(ClaudeCapturedSnapshot.self, from: url), + snapshot.belongs(to: accountFingerprint) + else { return nil } + return snapshot + } + static func readAccount( fetch: () throws -> ClaudeAccountSnapshot, recover: () -> Bool @@ -65,7 +86,9 @@ struct ClaudeUsageClient: Sendable { capture: ClaudeCapturedSnapshot?, modelBuckets: [ModelTokenBucket], now: Date, - activityReadSucceeded: Bool = true + activityReadSucceeded: Bool = true, + accountFingerprint: String? = nil, + accountLabel: String? = nil ) -> ClaudeUsageResult { if case .success(let live) = account, !live.windows.isEmpty { return ClaudeUsageResult( @@ -73,7 +96,9 @@ struct ClaudeUsageClient: Sendable { windows: live.windows, capturedAt: live.capturedAt, modelBuckets: modelBuckets, activityReadSucceeded: activityReadSucceeded), access: .live, - lastActivityAt: capture?.capturedAt + lastActivityAt: capture?.capturedAt, + accountFingerprint: accountFingerprint, + accountLabel: accountLabel ) } let fallback = Self.fallback(cached: cached, capture: capture, modelBuckets: modelBuckets, now: now) @@ -94,7 +119,9 @@ struct ClaudeUsageClient: Sendable { windows: fallback.windows, capturedAt: fallback.capturedAt, modelBuckets: modelBuckets, activityReadSucceeded: activityReadSucceeded), access: access, - lastActivityAt: capture?.capturedAt + lastActivityAt: capture?.capturedAt, + accountFingerprint: accountFingerprint, + accountLabel: accountLabel ) } @@ -144,18 +171,22 @@ struct ClaudeUsageClient: Sendable { private func fetchModelBuckets() throws -> [ModelTokenBucket] { guard let executable = resolveCaptureExecutable() else { throw UsageDataError.executableNotFound } - return try Self.historyBuckets(executable: executable) + return try Self.historyBuckets( + executable: executable, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)) } static func historyBuckets( executable: URL, arguments: [String] = ["--history"], - timeout: TimeInterval = 20 + timeout: TimeInterval = 20, + workingDirectory: URL ) throws -> [ModelTokenBucket] { let result: ProcessResult do { result = try ProcessRunner.run( - executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [], timeout: timeout + executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [], + timeout: timeout, workingDirectory: workingDirectory ) } catch UsageDataError.timedOut { throw UsageDataError.timedOut diff --git a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift index 75853fe..3d39278 100644 --- a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift +++ b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift @@ -25,7 +25,8 @@ struct CodexAppServerClient: Sendable { arguments: ["app-server", "--stdio"], input: Data(input.utf8), requiredResponseIDs: [2, 3, 4], - timeout: 8 + timeout: 8, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory) ) guard result.exitCode == 0 else { throw UsageDataError.processFailed("Codex app-server exited with status \(result.exitCode)") @@ -78,13 +79,19 @@ enum ProcessRunner { arguments: [String], input: Data, requiredResponseIDs: Set, - timeout: TimeInterval + timeout: TimeInterval, + workingDirectory: URL ) throws -> ProcessResult { + try FileManager.default.createDirectory( + at: workingDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) let process = Process() let inputPipe = Pipe() let outputPipe = Pipe() process.executableURL = executable process.arguments = arguments + process.currentDirectoryURL = workingDirectory var environment = ProcessInfo.processInfo.environment let inheritedPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" environment["PATH"] = executable.deletingLastPathComponent().path + ":" + inheritedPath diff --git a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift index dce218a..d7986a0 100644 --- a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift +++ b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift @@ -5,7 +5,7 @@ import os enum EffortHistoryClient { private static let collecting = OSAllocatedUnfairLock(initialState: false) - static func collect() throws { + static func collect(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) throws { let acquired = collecting.withLock { active in guard !active else { return false } active = true @@ -22,7 +22,8 @@ enum EffortHistoryClient { } let result = try ProcessRunner.run( executable: executable, arguments: ["--record-effort-history"], input: Data(), - requiredResponseIDs: [], timeout: 25) + requiredResponseIDs: [], timeout: 25, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)) guard result.exitCode == 0 else { throw UsageDataError.processFailed("Could not update effort history") } let receipt = try JSONDecoder().decode([String: Int].self, from: result.standardOutput) guard let records = receipt["records"], records >= 0 else { throw UsageDataError.invalidPayload } diff --git a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift index 2d1cd2c..d0b8b23 100644 --- a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift +++ b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift @@ -121,7 +121,7 @@ final class HistoryDashboardModel: ObservableObject { func load( mode: HistoryMode, revision: Int, previewSnapshots: [ProviderUsageSnapshot]? = nil, - paceKeys: [HistoryPaceKey] = [] + paceKeys: [HistoryPaceKey] = [], accountFingerprint: String? = nil ) async { let request = UUID() generation = request @@ -136,7 +136,9 @@ final class HistoryDashboardModel: ObservableObject { cacheOrder.removeAll(keepingCapacity: true) cacheRevision = revision } - let cacheKey = first + ":" + last + ":" + paceKeys.map(\.id).sorted().joined(separator: "|") + let cacheKey = + first + ":" + last + ":" + (accountFingerprint ?? "") + + ":" + paceKeys.map(\.id).sorted().joined(separator: "|") do { let result: ReadResult if let cached = cachedReads[cacheKey] { @@ -157,8 +159,10 @@ final class HistoryDashboardModel: ObservableObject { return ReadResult( days: Self.makeDays(interval: interval, tokens: tokens, efforts: efforts), latest: try UsageHistoryStore.recentPaces( - for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true), - retained: try UsageHistoryStore.recentPaces(for: paceKeys, activeOnly: true, before: now), + for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true, + accountFingerprint: accountFingerprint), + retained: try UsageHistoryStore.recentPaces( + for: paceKeys, activeOnly: true, before: now, accountFingerprint: accountFingerprint), first: try UsageHistoryStore.bounds().firstDay) }.value } diff --git a/Sources/TokenGaugeApp/Services/UsageStore.swift b/Sources/TokenGaugeApp/Services/UsageStore.swift index 4acb099..8f3370b 100644 --- a/Sources/TokenGaugeApp/Services/UsageStore.swift +++ b/Sources/TokenGaugeApp/Services/UsageStore.swift @@ -76,6 +76,8 @@ final class UsageStore: ObservableObject { recoveryAuthorization.setAllowed(claudeAutomaticRecovery && claudeCancelledAt == nil) } } + @Published private(set) var claudeAccountLabel: String? + @Published private(set) var claudeAccountFingerprint: String? @Published private(set) var claudeCancelledAt: Date? @Published private(set) var codexCancelledAt: Date? @@ -130,6 +132,15 @@ final class UsageStore: ObservableObject { } if claudeCancelledAt != nil { claude.status = .cancelled } if codexCancelledAt != nil { codex.status = .cancelled } + if self.historyReadsEnabled { + let identity = ClaudeAccountIdentityReader.current(homeDirectory: claudeClient.homeDirectory) + claudeAccountLabel = identity?.label + claudeAccountFingerprint = identity?.fingerprint + } + } + + func setPreviewAccountLabel(_ label: String?) { + claudeAccountLabel = label } func start() { @@ -156,6 +167,7 @@ final class UsageStore: ObservableObject { let claudeClient = self.claudeClient let codexClient = self.codexClient let recoveryAuthorization = self.recoveryAuthorization + let effortHome = claudeClient.homeDirectory Task { async let claudeOutcome = Task.detached(priority: .utility) { @@ -164,18 +176,23 @@ final class UsageStore: ObservableObject { async let codexOutcome = Task.detached(priority: .utility) { Self.fetchCodex(client: codexClient) }.value - let codexState = await codexOutcome applyCodexState(codexState) let claudeResult = await claudeOutcome applyRefreshResults(claudeResult: claudeResult, codexState: codexState) + if historyReadsEnabled { + claudeAccountLabel = claudeResult?.accountLabel + claudeAccountFingerprint = claudeResult?.accountFingerprint + } lastRefresh = Date() isRefreshing = false scheduleResetRefresh() if historyReadsEnabled { await Task.detached(priority: .background) { - Self.archive(claudeResult: claudeResult, codexState: codexState) - try? EffortHistoryClient.collect() + Self.archive( + claudeResult: claudeResult, codexState: codexState, + accountFingerprint: claudeResult?.accountFingerprint) + try? EffortHistoryClient.collect(homeDirectory: effortHome) }.value historyRevision &+= 1 } @@ -202,10 +219,12 @@ final class UsageStore: ObservableObject { nonisolated static func archive( claudeResult: ClaudeUsageResult?, codexState: ProviderViewState, - at url: URL = UsagePaths.history() + at url: URL = UsagePaths.history(), accountFingerprint: String? = nil ) { if let result = claudeResult { - try? UsageHistoryStore.record(result.snapshot, at: url, recordQuota: result.access == .live) + try? UsageHistoryStore.record( + result.snapshot, at: url, recordQuota: result.access == .live, + accountFingerprint: accountFingerprint) } if let snapshot = codexState.snapshot { try? UsageHistoryStore.record( diff --git a/Sources/TokenGaugeApp/Views/PopoverView.swift b/Sources/TokenGaugeApp/Views/PopoverView.swift index fd69f95..f572de3 100644 --- a/Sources/TokenGaugeApp/Views/PopoverView.swift +++ b/Sources/TokenGaugeApp/Views/PopoverView.swift @@ -53,7 +53,8 @@ struct PopoverView: View { } } await history.load( - mode: store.historyMode, revision: store.historyRevision, previewSnapshots: preview, paceKeys: keys) + mode: store.historyMode, revision: store.historyRevision, previewSnapshots: preview, + paceKeys: keys, accountFingerprint: store.claudeAccountFingerprint) } } @@ -149,6 +150,7 @@ struct PopoverView: View { claudeMenuBarSource: store.claudeMenuBarSource, claudeAutomaticRecovery: store.claudeAutomaticRecovery, showHourlyPace: store.showHourlyPace, + accountLabel: store.claudeAccountLabel, paces: paces(for: provider), previousPaces: previousPaces(for: provider) ) .frame(width: ringCardWidth(for: provider)) diff --git a/Sources/TokenGaugeApp/Views/ProviderCard.swift b/Sources/TokenGaugeApp/Views/ProviderCard.swift index fa2d832..497d654 100644 --- a/Sources/TokenGaugeApp/Views/ProviderCard.swift +++ b/Sources/TokenGaugeApp/Views/ProviderCard.swift @@ -12,6 +12,7 @@ struct ProviderCard: View { var claudeMenuBarSource: ClaudeMenuBarSource = .automatic var claudeAutomaticRecovery = false var showHourlyPace = false + var accountLabel: String? var paces: [String: QuotaPace] = [:] var previousPaces: [String: QuotaPace] = [:] @@ -144,6 +145,17 @@ struct ProviderCard: View { Text(statusSubtitle) .font(.system(size: 10)) .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(2) + if provider == .claude, let account = accountLabel, !account.isEmpty { + Text(account) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + .accessibilityLabel("account.current".localized(account)) + } if !showsTitle { Spacer(minLength: 4) } if state.status == .ready, let credits = state.snapshot?.availableResetCredits, credits > 0 { Text(UsageFormatters.resetCredits(credits)) diff --git a/Sources/TokenGaugeCapture/main.swift b/Sources/TokenGaugeCapture/main.swift index 7f46cae..4949e4d 100644 --- a/Sources/TokenGaugeCapture/main.swift +++ b/Sources/TokenGaugeCapture/main.swift @@ -25,7 +25,10 @@ enum TokenGaugeCapture { return } let data = FileHandle.standardInput.readDataToEndOfFile() - guard let snapshot = try? ClaudeUsageParser.capture(from: data) else { return } + guard + let snapshot = try? ClaudeUsageParser.capture( + from: data, accountFingerprint: ClaudeAccountIdentityReader.current()?.fingerprint) + else { return } try SecureMetricStore.write(snapshot, to: UsagePaths.claudeCapture()) } } diff --git a/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift new file mode 100644 index 0000000..8372416 --- /dev/null +++ b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift @@ -0,0 +1,106 @@ +import CryptoKit +import Foundation +import os + +public struct ClaudeAccountIdentity: Equatable, Sendable { + public let accountUuid: String + public let emailAddress: String? + public let displayName: String? + public let organizationName: String? + + public init( + accountUuid: String, + emailAddress: String? = nil, + displayName: String? = nil, + organizationName: String? = nil + ) { + self.accountUuid = accountUuid + self.emailAddress = emailAddress + self.displayName = displayName + self.organizationName = organizationName + } + + public var fingerprint: String { + let hex = Array("0123456789abcdef".utf8) + return String( + decoding: SHA256.hash(data: Data(accountUuid.utf8)).flatMap { + [hex[Int($0 >> 4)], hex[Int($0 & 15)]] + }, as: UTF8.self) + } + + public static func isCompatible(stored: String?, current: String?) -> Bool { + guard let stored, let current else { return true } + return stored == current + } + + public var label: String? { + for candidate in [emailAddress, displayName] { + if let candidate, !candidate.isEmpty { return candidate } + } + return nil + } +} + +extension ClaudeAccountIdentity { + private struct Document: Decodable { + struct Account: Decodable { + let accountUuid: String + let emailAddress: String? + let displayName: String? + let organizationName: String? + } + + let oauthAccount: Account? + } + + public init?(data: Data) { + guard let account = (try? JSONDecoder().decode(Document.self, from: data))?.oauthAccount, + !account.accountUuid.isEmpty + else { return nil } + self.init( + accountUuid: account.accountUuid, + emailAddress: account.emailAddress, + displayName: account.displayName, + organizationName: account.organizationName + ) + } +} + +public enum ClaudeAccountIdentityReader { + private struct Entry { + let url: URL + let modifiedAt: Date? + let size: Int + let identity: ClaudeAccountIdentity? + } + + private static let cache = OSAllocatedUnfairLock(initialState: nil) + + public static func current( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ClaudeAccountIdentity? { + read(at: UsagePaths.claudeConfig(homeDirectory: homeDirectory)) + } + + public static func read(at url: URL) -> ClaudeAccountIdentity? { + cache.withLock { entry in + let previous = entry?.url == url ? entry?.identity : nil + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return previous + } + let modifiedAt = attributes[.modificationDate] as? Date + let size = attributes[.size] as? Int ?? 0 + if let entry, entry.url == url, entry.modifiedAt == modifiedAt, entry.size == size { + return entry.identity + } + guard let data = try? Data(contentsOf: url) else { return previous } + let identity = ClaudeAccountIdentity(data: data) + entry = Entry(url: url, modifiedAt: modifiedAt, size: size, identity: identity) + return identity + } + } + + public static func invalidate() { + cache.withLock { $0 = nil } + } +} diff --git a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift index cd88135..c66798e 100644 --- a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift +++ b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift @@ -3,10 +3,16 @@ import Foundation public struct ClaudeAccountSnapshot: Codable, Equatable, Sendable { public let capturedAt: Date public let windows: [QuotaWindow] + public let accountFingerprint: String? - public init(capturedAt: Date, windows: [QuotaWindow]) { + public init(capturedAt: Date, windows: [QuotaWindow], accountFingerprint: String? = nil) { self.capturedAt = capturedAt self.windows = windows + self.accountFingerprint = accountFingerprint + } + + public func belongs(to fingerprint: String?) -> Bool { + ClaudeAccountIdentity.isCompatible(stored: accountFingerprint, current: fingerprint) } } diff --git a/Sources/TokenGaugeCore/ClaudeOAuthToken.swift b/Sources/TokenGaugeCore/ClaudeOAuthToken.swift index 4a6ea54..2503a78 100644 --- a/Sources/TokenGaugeCore/ClaudeOAuthToken.swift +++ b/Sources/TokenGaugeCore/ClaudeOAuthToken.swift @@ -15,17 +15,35 @@ public struct ClaudeOAuthToken: Sendable { public enum ClaudeOAuthTokenReader { public static let service = "Claude Code-credentials" - private static let cache = OSAllocatedUnfairLock(initialState: nil) + private struct CachedToken { + let token: ClaudeOAuthToken + let accountUuid: String? + } + + private static let cache = OSAllocatedUnfairLock(initialState: nil) + + public static func read(account: String = NSUserName(), accountUuid: String? = nil) -> ClaudeOAuthToken? { + read(accountUuid: accountUuid) { readFromKeychain(account: account) } + } - public static func read(account: String = NSUserName()) -> ClaudeOAuthToken? { + static func read(accountUuid: String?, load: @Sendable () -> ClaudeOAuthToken?) -> ClaudeOAuthToken? { cache.withLock { cached in - if let cached, !cached.isExpired { return cached } - let fresh = readFromKeychain(account: account) - if fresh != nil { cached = fresh } + if let current = cached, !current.token.isExpired, + !isForeign(cached: current.accountUuid, requested: accountUuid) + { + return current.token + } + let fresh = load() + if let fresh { cached = CachedToken(token: fresh, accountUuid: accountUuid) } return fresh } } + static func isForeign(cached: String?, requested: String?) -> Bool { + guard let requested else { return false } + return cached != requested + } + public static func invalidate() { cache.withLock { $0 = nil } } diff --git a/Sources/TokenGaugeCore/ClaudeUsageParser.swift b/Sources/TokenGaugeCore/ClaudeUsageParser.swift index c7ce062..5d4d43b 100644 --- a/Sources/TokenGaugeCore/ClaudeUsageParser.swift +++ b/Sources/TokenGaugeCore/ClaudeUsageParser.swift @@ -13,15 +13,25 @@ public struct ClaudeCapturedWindow: Codable, Equatable, Sendable { public struct ClaudeCapturedSnapshot: Codable, Equatable, Sendable { public let capturedAt: Date public let windows: [String: ClaudeCapturedWindow] + public let accountFingerprint: String? - public init(capturedAt: Date, windows: [String: ClaudeCapturedWindow]) { + public init( + capturedAt: Date, windows: [String: ClaudeCapturedWindow], accountFingerprint: String? = nil + ) { self.capturedAt = capturedAt self.windows = windows + self.accountFingerprint = accountFingerprint + } + + public func belongs(to fingerprint: String?) -> Bool { + ClaudeAccountIdentity.isCompatible(stored: accountFingerprint, current: fingerprint) } } public enum ClaudeUsageParser { - public static func capture(from statusLineData: Data, capturedAt: Date = Date()) throws -> ClaudeCapturedSnapshot { + public static func capture( + from statusLineData: Data, capturedAt: Date = Date(), accountFingerprint: String? = nil + ) throws -> ClaudeCapturedSnapshot { guard let root = try JSONSerialization.jsonObject(with: statusLineData) as? [String: Any], let rateLimits = JSONValue.dictionary(root["rate_limits"]) @@ -37,7 +47,8 @@ public enum ClaudeUsageParser { windows[key] = ClaudeCapturedWindow(usedPercentage: used, resetsAt: reset) } guard !windows.isEmpty else { throw UsageDataError.invalidPayload } - return ClaudeCapturedSnapshot(capturedAt: capturedAt, windows: windows) + return ClaudeCapturedSnapshot( + capturedAt: capturedAt, windows: windows, accountFingerprint: accountFingerprint) } public static func normalize( diff --git a/Sources/TokenGaugeCore/UsageHistoryStore.swift b/Sources/TokenGaugeCore/UsageHistoryStore.swift index c828cde..02a5e04 100644 --- a/Sources/TokenGaugeCore/UsageHistoryStore.swift +++ b/Sources/TokenGaugeCore/UsageHistoryStore.swift @@ -32,6 +32,7 @@ public struct HistoryQuotaRow: Equatable, Sendable { public let isVerified: Bool public let continuityResetAt: Date? public let continuityStartedAt: Date? + public let accountFingerprint: String? public init( sampledAt: Date, @@ -43,7 +44,8 @@ public struct HistoryQuotaRow: Equatable, Sendable { durationMinutes: Int? = nil, isVerified: Bool = false, continuityStartedAt: Date? = nil, - continuityResetAt: Date? = nil + continuityResetAt: Date? = nil, + accountFingerprint: String? = nil ) { self.sampledAt = sampledAt self.provider = provider @@ -55,6 +57,7 @@ public struct HistoryQuotaRow: Equatable, Sendable { self.isVerified = isVerified self.continuityStartedAt = continuityStartedAt self.continuityResetAt = continuityResetAt + self.accountFingerprint = accountFingerprint } } @@ -67,14 +70,17 @@ public enum UsageHistoryStore { at url: URL = UsagePaths.history(), now: Date = Date(), recordQuota: Bool = true, - recordTokens: Bool = true + recordTokens: Bool = true, + accountFingerprint: String? = nil ) throws { let handle = try open(url) defer { sqlite3_close(handle) } try prepareSchema(handle) try exec(handle, "BEGIN IMMEDIATE;") do { - if recordQuota { try writeQuota(handle, snapshot: snapshot) } + if recordQuota { + try writeQuota(handle, snapshot: snapshot, accountFingerprint: accountFingerprint) + } if recordTokens && snapshot.activityReadSucceeded { try writeTokens(handle, snapshot: snapshot, now: now) } @@ -177,7 +183,7 @@ public enum UsageHistoryStore { ) throws -> [HistoryQuotaRow] { let sql = """ SELECT COALESCE(observed_at, sampled_at), provider, window_id, display_name, used_percentage, resets_at, - duration_minutes, verified, continuity_started_at, continuity_reset_at + duration_minutes, verified, continuity_started_at, continuity_reset_at, account_fingerprint FROM quota_samples WHERE sampled_at >= ?2 AND sampled_at <= ?3 AND (?1 IS NULL OR provider = ?1) AND COALESCE(observed_at, sampled_at) >= ?4 AND COALESCE(observed_at, sampled_at) <= ?5 @@ -203,7 +209,8 @@ public enum UsageHistoryStore { durationMinutes: sqlite3_column_type(statement, 6) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(statement, 6)), isVerified: sqlite3_column_int(statement, 7) == 1, - continuityStartedAt: date(statement, 8), continuityResetAt: date(statement, 9) + continuityStartedAt: date(statement, 8), continuityResetAt: date(statement, 9), + accountFingerprint: optionalText(statement, 10) ) ) } @@ -212,7 +219,7 @@ public enum UsageHistoryStore { public static func paceRows( provider: UsageProvider? = nil, windowID: String? = nil, since: Date? = nil, until: Date? = nil, - at url: URL = UsagePaths.history() + accountFingerprint: String? = nil, at url: URL = UsagePaths.history() ) throws -> [HistoryPaceRow] { let handle = try open(url) defer { sqlite3_close(handle) } @@ -222,6 +229,7 @@ public enum UsageHistoryStore { suffix: """ WHERE sampled_at >= ?1 AND sampled_at <= ?2 AND (?3 IS NULL OR provider = ?3) AND (?4 IS NULL OR window_id = ?4) + AND account_fingerprint IS ?5 ORDER BY sampled_at, provider, window_id """ ) { statement in @@ -229,12 +237,14 @@ public enum UsageHistoryStore { sqlite3_bind_double(statement, 2, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude) bind(statement, 3, provider?.rawValue) bind(statement, 4, windowID) + bind(statement, 5, accountFingerprint) } } public static func recentPaces( for keys: [HistoryPaceKey], activeOnly: Bool = false, limitPerWindow: Int = 2, - before: Date? = nil, matchingLatestQuota: Bool = false, at url: URL = UsagePaths.history() + before: Date? = nil, matchingLatestQuota: Bool = false, accountFingerprint: String? = nil, + at url: URL = UsagePaths.history() ) throws -> [HistoryPaceRow] { guard !keys.isEmpty, limitPerWindow > 0 else { return [] } let handle = try open(url) @@ -243,7 +253,8 @@ public enum UsageHistoryStore { var rows: [HistoryPaceRow] = [] for key in Set(keys).sorted(by: { $0.id < $1.id }) { let candidates = try readRecentPaces( - handle, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before) + handle, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before, + accountFingerprint: accountFingerprint) if matchingLatestQuota { let quota = try latestQuota(handle, provider: key.provider, windowID: key.windowID) rows += candidates.filter { quota?.isVerified == true && $0.pace.sampledAt == quota?.sampledAt } @@ -255,13 +266,15 @@ public enum UsageHistoryStore { } private static func readRecentPaces( - _ handle: OpaquePointer, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date? + _ handle: OpaquePointer, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date?, + accountFingerprint: String? = nil ) throws -> [HistoryPaceRow] { let active = activeOnly ? "AND is_active = 1" : "" return try readPaces( handle, suffix: """ WHERE provider = ?1 AND window_id = ?2 AND sampled_at < ?3 \(active) + AND account_fingerprint IS ?5 ORDER BY sampled_at DESC LIMIT ?4 """ ) { statement in @@ -269,6 +282,7 @@ public enum UsageHistoryStore { bind(statement, 2, key.windowID) sqlite3_bind_double(statement, 3, before?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude) sqlite3_bind_int64(statement, 4, Int64(limit)) + bind(statement, 5, accountFingerprint) } } @@ -301,7 +315,7 @@ public enum UsageHistoryStore { private static func archivePace( _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, window: QuotaWindow, - previous: HistoryQuotaRow?, now: Date + previous: HistoryQuotaRow?, now: Date, accountFingerprint: String? ) throws { guard window.durationMinutes == 10_080 else { return } let rows = try readQuotaRows( @@ -315,15 +329,16 @@ public enum UsageHistoryStore { && HistoryAnalytics.sameReset($0.resetsAt, window.resetsAt) && window.usedPercentage > $0.usedPercentage } ?? false - let activeHistory = try readRecentPaces(handle, key: key, activeOnly: true, limit: 1, before: nil) + let activeHistory = try readRecentPaces( + handle, key: key, activeOnly: true, limit: 1, before: nil, accountFingerprint: accountFingerprint) let seed = pace.pointsPerHour > 0 && activeHistory.isEmpty try run( handle, """ INSERT OR IGNORE INTO pace_samples (provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes, - resets_at, last_used_percentage, is_active) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9); + resets_at, last_used_percentage, is_active, account_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10); """ ) { statement in bind(statement, 1, snapshot.provider.rawValue) @@ -339,6 +354,7 @@ public enum UsageHistoryStore { } sqlite3_bind_double(statement, 8, window.usedPercentage) sqlite3_bind_int(statement, 9, increased || seed ? 1 : 0) + bind(statement, 10, accountFingerprint) } } @@ -451,6 +467,16 @@ extension UsageHistoryStore { if !columns.contains("continuity_reset_at") { try exec(handle, "ALTER TABLE quota_samples ADD COLUMN continuity_reset_at REAL;") } + if !columns.contains("account_fingerprint") { + try exec(handle, "ALTER TABLE quota_samples ADD COLUMN account_fingerprint TEXT;") + } + var paceColumns = Set() + try query(handle, "PRAGMA table_info(pace_samples);", bindings: { _ in }) { statement in + paceColumns.insert(text(statement, 1)) + } + if !paceColumns.contains("account_fingerprint") { + try exec(handle, "ALTER TABLE pace_samples ADD COLUMN account_fingerprint TEXT;") + } var version: Int32 = 0 try query(handle, "PRAGMA user_version;", bindings: { _ in }) { statement in version = sqlite3_column_int(statement, 0) @@ -489,13 +515,15 @@ extension UsageHistoryStore { } } - private static func writeQuota(_ handle: OpaquePointer, snapshot: ProviderUsageSnapshot) throws { + private static func writeQuota( + _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, accountFingerprint: String? + ) throws { guard let capturedAt = snapshot.capturedAt else { return } let sampledAt = bucket(capturedAt) let sql = """ INSERT INTO quota_samples - (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11) + (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at, account_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11, ?12) ON CONFLICT(provider, window_id, sampled_at) DO UPDATE SET display_name = excluded.display_name, duration_minutes = excluded.duration_minutes, @@ -504,7 +532,8 @@ extension UsageHistoryStore { verified = excluded.verified, observed_at = excluded.observed_at, continuity_started_at = excluded.continuity_started_at, - continuity_reset_at = excluded.continuity_reset_at + continuity_reset_at = excluded.continuity_reset_at, + account_fingerprint = excluded.account_fingerprint WHERE excluded.observed_at >= COALESCE(quota_samples.observed_at, quota_samples.sampled_at); """ for window in snapshot.windows { @@ -515,6 +544,7 @@ extension UsageHistoryStore { var continuityResetAt = window.resetsAt if let previous, previous.isVerified, valid, previous.usedPercentage <= window.usedPercentage, + previous.accountFingerprint == accountFingerprint, HistoryAnalytics.sameReset(previous.continuityResetAt ?? previous.resetsAt, window.resetsAt), previous.durationMinutes == window.durationMinutes, capturedAt.timeIntervalSince(previous.sampledAt) <= 1800, @@ -543,8 +573,11 @@ extension UsageHistoryStore { } else { sqlite3_bind_null(statement, 11) } + bind(statement, 12, accountFingerprint) } - try archivePace(handle, snapshot: snapshot, window: window, previous: previous, now: capturedAt) + try archivePace( + handle, snapshot: snapshot, window: window, previous: previous, now: capturedAt, + accountFingerprint: accountFingerprint) } } @@ -553,7 +586,7 @@ extension UsageHistoryStore { ) throws -> HistoryQuotaRow? { let sql = """ SELECT COALESCE(observed_at, sampled_at), used_percentage, resets_at, duration_minutes, - verified, continuity_started_at, continuity_reset_at + verified, continuity_started_at, continuity_reset_at, account_fingerprint FROM quota_samples WHERE provider = ?1 AND window_id = ?2 ORDER BY sampled_at DESC LIMIT 1; """ var row: HistoryQuotaRow? @@ -568,7 +601,7 @@ extension UsageHistoryStore { durationMinutes: sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(statement, 3)), isVerified: sqlite3_column_int(statement, 4) == 1, continuityStartedAt: date(statement, 5), - continuityResetAt: date(statement, 6)) + continuityResetAt: date(statement, 6), accountFingerprint: optionalText(statement, 7)) } return row } diff --git a/Sources/TokenGaugeCore/UsagePaths.swift b/Sources/TokenGaugeCore/UsagePaths.swift index 400dbdc..b65e025 100644 --- a/Sources/TokenGaugeCore/UsagePaths.swift +++ b/Sources/TokenGaugeCore/UsagePaths.swift @@ -21,7 +21,17 @@ public enum UsagePaths { supportDirectory(homeDirectory: homeDirectory).appending(path: "usage-history.sqlite") } + public static func recoveryWorkingDirectory( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + supportDirectory(homeDirectory: homeDirectory).appending(path: "recovery", directoryHint: .isDirectory) + } + public static func claudeProjects(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { homeDirectory.appending(path: ".claude/projects", directoryHint: .isDirectory) } + + public static func claudeConfig(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + homeDirectory.appending(path: ".claude.json") + } } diff --git a/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift new file mode 100644 index 0000000..b4d7a0c --- /dev/null +++ b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +@MainActor +final class ClaudeAccountHeaderRenderingTests: XCTestCase { + private let label = "very.long.account.name.for.layout@example.com" + + func testLongAccountLabelKeepsTheHeaderOnOneRowAndTruncatesOnlyItself() throws { + let language = LocalizationManager.shared.language + defer { LocalizationManager.shared.language = language } + for language in AppLanguage.allCases { + LocalizationManager.shared.language = language + let plain = try render(card(accountLabel: nil)) + let labelled = try render(card(accountLabel: label)) + let longer = try render(card(accountLabel: label + label)) + XCTAssertEqual(labelled.size.width, Theme.Layout.compactPanelWidth, accuracy: 1) + XCTAssertEqual(labelled.size.height, plain.size.height, accuracy: 1) + XCTAssertEqual(longer.size.width, labelled.size.width, accuracy: 1) + XCTAssertEqual(longer.size.height, labelled.size.height, accuracy: 1) + } + } + + func testPreviewStoresExposeTheAccountLabelWithoutReadingTheRealConfiguration() throws { + let suite = "TokenGauge.account.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = UsageStore(defaults: defaults, initialSnapshots: [snapshot()], historyReadsEnabled: false) + + XCTAssertNil(store.claudeAccountLabel) + store.setPreviewAccountLabel(label) + XCTAssertEqual(store.claudeAccountLabel, label) + XCTAssertNil(store.claudeAccountFingerprint) + let view = NSHostingView(rootView: PopoverView(store: store, showSettings: {}, showAbout: {})) + XCTAssertLessThanOrEqual(view.fittingSize.height, Theme.Layout.maximumPanelHeight) + } + + private func card(accountLabel: String?) -> some View { + ProviderCard( + provider: .claude, state: ProviderViewState(snapshot: snapshot(), status: .ready, isRefreshing: false), + showProviderTitle: true, accountLabel: accountLabel + ) + .frame(width: Theme.Layout.compactPanelWidth) + } + + private func snapshot() -> ProviderUsageSnapshot { + ProviderUsageSnapshot( + provider: .claude, + windows: [ + QuotaWindow( + id: "seven_day", usedPercentage: 30, resetsAt: Date().addingTimeInterval(86_400), + durationMinutes: 10_080, displayName: nil) + ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, + capturedAt: Date().addingTimeInterval(-600)) + } + + private func render(_ content: some View) throws -> NSBitmapImageRep { + let view = NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false)) + view.frame = NSRect(origin: .zero, size: view.fittingSize) + let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = view + defer { window.contentView = nil } + view.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + return bitmap + } +} diff --git a/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift b/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift new file mode 100644 index 0000000..1820eb3 --- /dev/null +++ b/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift @@ -0,0 +1,92 @@ +import Foundation +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +final class ClaudeAccountScopeTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 2_000_000_000) + + func testSnapshotOfAnotherAccountIsNotPresentedAsLiveData() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + try writeSnapshot(home: home, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + let client = ClaudeAccountUsageClient(homeDirectory: home) + let identity = ClaudeAccountIdentityReader.current(homeDirectory: home) + + XCTAssertNil(client.cached(identity: identity)) + let result = ClaudeUsageClient.resolve( + account: .failure(ClaudeAccountUsageError.unavailable), cached: client.cached(identity: identity), + capture: nil, modelBuckets: [], now: now) + XCTAssertEqual(result.access, .unavailable) + XCTAssertTrue(result.snapshot.windows.isEmpty) + } + + func testSameAccountAndLegacySnapshotsStayValid() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + let client = ClaudeAccountUsageClient(homeDirectory: home) + let identity = ClaudeAccountIdentityReader.current(homeDirectory: home) + + try writeSnapshot(home: home, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint) + XCTAssertEqual(client.cached(identity: identity)?.capturedAt, now.addingTimeInterval(-1)) + + try writeSnapshot(home: home, fingerprint: nil) + XCTAssertEqual(client.cached(identity: identity)?.capturedAt, now.addingTimeInterval(-1)) + XCTAssertNil(client.cached(identity: identity)?.accountFingerprint) + XCTAssertNotNil(client.cached(identity: nil)) + } + + func testStatusLineCaptureOfAnotherAccountIsNeverUsedAsFallback() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + let url = UsagePaths.claudeCapture(homeDirectory: home) + let current = ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint + + try writeCapture(at: url, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + XCTAssertNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + let result = ClaudeUsageClient.resolve( + account: .failure(ClaudeAccountUsageError.unavailable), cached: nil, + capture: ClaudeUsageClient.capture(at: url, accountFingerprint: current), modelBuckets: [], now: now) + XCTAssertEqual(result.access, .unavailable) + XCTAssertTrue(result.snapshot.windows.isEmpty) + + try writeCapture(at: url, fingerprint: current) + XCTAssertNotNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + try writeCapture(at: url, fingerprint: nil) + XCTAssertNotNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + } + + private func makeHome(accountUuid: String) throws -> URL { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try Data(#"{"oauthAccount":{"accountUuid":"\#(accountUuid)"}}"#.utf8) + .write(to: UsagePaths.claudeConfig(homeDirectory: home)) + return home + } + + private func writeSnapshot(home: URL, fingerprint: String?) throws { + let snapshot = ClaudeAccountSnapshot( + capturedAt: now.addingTimeInterval(-1), + windows: [ + QuotaWindow( + id: "seven_day", usedPercentage: 40, resetsAt: now.addingTimeInterval(86_400), + durationMinutes: 10_080, displayName: nil) + ], + accountFingerprint: fingerprint + ) + try SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: home)) + } + + private func writeCapture(at url: URL, fingerprint: String?) throws { + let capture = ClaudeCapturedSnapshot( + capturedAt: now.addingTimeInterval(-1), + windows: [ + "five_hour": ClaudeCapturedWindow(usedPercentage: 30, resetsAt: now.addingTimeInterval(3_600)) + ], + accountFingerprint: fingerprint + ) + try SecureMetricStore.write(capture, to: url) + } +} diff --git a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift index 6c76765..0700117 100644 --- a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift @@ -21,7 +21,8 @@ final class ClaudeHistoryProcessTests: XCTestCase { let actual = try ClaudeUsageClient.historyBuckets( executable: URL(filePath: "/bin/sh"), arguments: ["-c", "/usr/bin/head -c 131072 /dev/zero >&2; /bin/cat \"$1\"", "history-test", file.path], - timeout: 3 + timeout: 3, + workingDirectory: workingDirectory() ) XCTAssertEqual(actual, buckets) } @@ -49,7 +50,8 @@ final class ClaudeHistoryProcessTests: XCTestCase { "-c", "trap '' TERM; printf '%s' $$ > \"$1\"; printf 'private' >&2; while :; do :; done", "history-test", pidFile.path, ], - timeout: 0.2 + timeout: 0.2, + workingDirectory: workingDirectory() ) ) { error in XCTAssertEqual(error as? UsageDataError, .timedOut) @@ -62,10 +64,17 @@ final class ClaudeHistoryProcessTests: XCTestCase { private func run(_ script: String) throws -> [ModelTokenBucket] { try ClaudeUsageClient.historyBuckets( - executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], timeout: 2 + executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], timeout: 2, + workingDirectory: workingDirectory() ) } + private func workingDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + private func temporaryFile() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) } diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift index f1015df..d815a05 100644 --- a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift @@ -17,7 +17,8 @@ final class ClaudeRecoveryLiveTests: XCTestCase { fetch: { reads += 1 if reads == 1 { throw ClaudeAccountUsageError.credentialExpired } - return try ClaudeAccountUsageClient(homeDirectory: home).fetch() + return try ClaudeAccountUsageClient(homeDirectory: home).fetch( + identity: ClaudeAccountIdentityReader.current(homeDirectory: home)) }, recover: { ClaudeRecoveryProcess.run(executable: executable, homeDirectory: home) { diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift index 39f0337..a23d5a1 100644 --- a/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TokenGaugeCore import XCTest @testable import TokenGaugeApp @@ -11,14 +12,14 @@ final class ClaudeRecoveryProcessTests: XCTestCase { let marker = directory.appending(path: "ready") let script = """ test -t 0 && test -t 1 && test -t 2 || exit 1 - test "$PWD" -ef "$HOME" || exit 2 + test "$PWD" -ef "$HOME/Library/Application Support/TokenGauge/recovery" || exit 2 test -z "$ANTHROPIC_API_KEY$CLAUDECODE$NODE_OPTIONS$TMUX" || exit 3 case "$PATH" in *evil*) exit 4;; esac stty -icanon min 0 time 1 test "$(dd bs=1 count=1 2>/dev/null | wc -c | tr -d ' ')" = 0 || exit 5 /usr/bin/head -c 1048576 /dev/zero /usr/bin/head -c 1048576 /dev/zero >&2 - touch ready + touch "$HOME/ready" sleep 10 """ var checks: [TimeInterval] = [] @@ -51,9 +52,9 @@ final class ClaudeRecoveryProcessTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let script = """ trap '' TERM - echo $$ > parent + echo $$ > "$HOME/parent" /bin/sh -c 'trap "" TERM; while :; do sleep 1; done' & - echo $! > child + echo $! > "$HOME/child" wait """ let start = ProcessInfo.processInfo.systemUptime @@ -73,7 +74,7 @@ final class ClaudeRecoveryProcessTests: XCTestCase { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } let executable = directory.appending(path: "fake-claude") - try "#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = --safe-mode ] && touch ready\nsleep 10\n" + try "#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = --safe-mode ] && touch \"$HOME/ready\"\nsleep 10\n" .write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) XCTAssertTrue( @@ -91,7 +92,8 @@ final class ClaudeRecoveryProcessTests: XCTestCase { XCTAssertFalse( ClaudeRecoveryProcess.run( executable: URL(filePath: "/bin/sh"), homeDirectory: directory, timeout: 10, - arguments: ["-c", "trap '' TERM; echo $$ > parent; while :; do sleep 1; done"], environment: [:], + arguments: ["-c", "trap '' TERM; echo $$ > \"$HOME/parent\"; while :; do sleep 1; done"], + environment: [:], shouldContinue: { !FileManager.default.fileExists(atPath: marker.path) } ) { false } ) @@ -118,6 +120,43 @@ final class ClaudeRecoveryProcessTests: XCTestCase { }) } + func testChildRunsInDedicatedDirectoryCreatedPrivatelyInsteadOfHome() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recovery = UsagePaths.recoveryWorkingDirectory(homeDirectory: directory) + XCTAssertFalse(FileManager.default.fileExists(atPath: recovery.path)) + XCTAssertFalse( + ClaudeRecoveryProcess.run( + executable: URL(filePath: "/bin/sh"), homeDirectory: directory, timeout: 4, + arguments: ["-c", "pwd > \"$TMPDIR/cwd\""], environment: ["TMPDIR": directory.path] + ) { false } + ) + let value = try String(contentsOf: directory.appending(path: "cwd"), encoding: .utf8) + let observed = URL(filePath: value.trimmingCharacters(in: .whitespacesAndNewlines)) + XCTAssertEqual(observed.resolvingSymlinksInPath().path, recovery.resolvingSymlinksInPath().path) + XCTAssertNotEqual(observed.resolvingSymlinksInPath().path, directory.resolvingSymlinksInPath().path) + let permissions = + try FileManager.default.attributesOfItem(atPath: recovery.path)[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o700) + } + + func testAppServerAndCaptureHelperRunInTheSameDedicatedDirectory() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recovery = UsagePaths.recoveryWorkingDirectory(homeDirectory: directory) + let result = try ProcessRunner.run( + executable: URL(filePath: "/bin/sh"), arguments: ["-c", "pwd"], input: Data(), + requiredResponseIDs: [], timeout: 4, workingDirectory: recovery + ) + let value = String(decoding: result.standardOutput, as: UTF8.self) + let observed = URL(filePath: value.trimmingCharacters(in: .whitespacesAndNewlines)) + XCTAssertEqual(observed.resolvingSymlinksInPath().path, recovery.resolvingSymlinksInPath().path) + XCTAssertNotEqual(observed.resolvingSymlinksInPath().path, directory.resolvingSymlinksInPath().path) + let permissions = + try FileManager.default.attributesOfItem(atPath: recovery.path)[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o700) + } + private func run( _ script: String, directory: URL, timeout: TimeInterval, recovered: () -> Bool ) -> Bool { diff --git a/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift b/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift index 402ce1e..dd3e179 100644 --- a/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift +++ b/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift @@ -90,10 +90,16 @@ final class PortableRuntimeTests: XCTestCase { XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - start, 2) } + private func workingDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + private func run(_ script: String, input: String = "", timeout: TimeInterval = 2) throws -> ProcessResult { try ProcessRunner.run( executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], input: Data(input.utf8), - requiredResponseIDs: [3, 4], timeout: timeout + requiredResponseIDs: [3, 4], timeout: timeout, workingDirectory: workingDirectory() ) } } diff --git a/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift new file mode 100644 index 0000000..9165419 --- /dev/null +++ b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift @@ -0,0 +1,94 @@ +import Foundation +import XCTest + +@testable import TokenGaugeCore + +final class ClaudeAccountIdentityTests: XCTestCase { + func testDecodesAccountAndIgnoresUnknownKeys() { + let payload = Data( + #""" + {"numStartups":12,"projects":{"/tmp/a":{"allowedTools":[]}}, + "oauthAccount":{"accountUuid":"uuid-a","emailAddress":"one@example.com", + "displayName":"One","organizationName":"Org","seatTier":"max"}} + """#.utf8 + ) + let identity = ClaudeAccountIdentity(data: payload) + XCTAssertEqual(identity?.accountUuid, "uuid-a") + XCTAssertEqual(identity?.emailAddress, "one@example.com") + XCTAssertEqual(identity?.displayName, "One") + XCTAssertEqual(identity?.organizationName, "Org") + XCTAssertEqual(identity?.label, "one@example.com") + } + + func testFallsBackToDisplayNameAndRejectsIncompletePayloads() { + let onlyName = ClaudeAccountIdentity( + data: Data(#"{"oauthAccount":{"accountUuid":"uuid-b","displayName":"Two"}}"#.utf8)) + XCTAssertEqual(onlyName?.label, "Two") + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"numStartups":1}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"oauthAccount":{"emailAddress":"one@example.com"}}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"oauthAccount":{"accountUuid":""}}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data("not json".utf8))) + } + + func testFingerprintIsAStableHexDigestThatHidesTheUuid() { + let identity = ClaudeAccountIdentity(accountUuid: "uuid-a", emailAddress: "one@example.com") + XCTAssertEqual(identity.fingerprint, ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint) + XCTAssertNotEqual(identity.fingerprint, ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + XCTAssertEqual(identity.fingerprint.count, 64) + XCTAssertFalse(identity.fingerprint.contains("uuid-a")) + XCTAssertTrue(identity.fingerprint.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + func testReturnsNilForMissingFile() { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + XCTAssertNil(ClaudeAccountIdentityReader.current(homeDirectory: home)) + } + + func testRereadsOnlyWhenModificationDateOrSizeChanged() throws { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let file = UsagePaths.claudeConfig(homeDirectory: home) + try Data(#"{"oauthAccount":{"accountUuid":"uuid-a"}}"#.utf8).write(to: file) + let stamp = Date(timeIntervalSince1970: 1_756_300_000) + try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path) + + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try Data(#"{"oauthAccount":{"accountUuid":"uuid-b"}}"#.utf8).write(to: file) + try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try FileManager.default.setAttributes( + [.modificationDate: stamp.addingTimeInterval(60)], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-b") + } + func testTransientReadFailureKeepsTheLastIdentityButLogoutClearsIt() throws { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: UsagePaths.claudeConfig(homeDirectory: home).path) + try? FileManager.default.removeItem(at: home) + } + let file = UsagePaths.claudeConfig(homeDirectory: home) + try Data(#"{"oauthAccount":{"accountUuid":"uuid-a"}}"#.utf8).write(to: file) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: file.path) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_756_400_000)], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + let moved = home.appending(path: "moved.json") + try FileManager.default.moveItem(at: file, to: moved) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try Data(#"{"numStartups":3}"#.utf8).write(to: file) + XCTAssertNil(ClaudeAccountIdentityReader.current(homeDirectory: home)) + } +} diff --git a/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift b/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift index 8550f20..34f428a 100644 --- a/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift +++ b/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift @@ -18,4 +18,48 @@ final class ClaudeOAuthTokenTests: XCTestCase { XCTAssertNil(ClaudeOAuthTokenReader.parse(Data(#"{"other":1}"#.utf8))) XCTAssertNil(ClaudeOAuthTokenReader.parse(Data("not json".utf8))) } + + func testAccountSwitchReadsTheKeychainOnceAndKeepsTheCacheOtherwise() { + ClaudeOAuthTokenReader.invalidate() + nonisolated(unsafe) var reads = 0 + let load: (String) -> @Sendable () -> ClaudeOAuthToken? = { value in + { + reads += 1 + return ClaudeOAuthToken(value: value, expiresAt: Date().addingTimeInterval(3600)) + } + } + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-a", load: load("token-a"))?.value, "token-a") + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-a", load: load("token-a"))?.value, "token-a") + XCTAssertEqual(reads, 1) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: nil, load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + ClaudeOAuthTokenReader.invalidate() + } + + func testUntaggedCacheIsNeverPromotedToARequestedAccount() { + ClaudeOAuthTokenReader.invalidate() + nonisolated(unsafe) var reads = 0 + let load: (String) -> @Sendable () -> ClaudeOAuthToken? = { value in + { + reads += 1 + return ClaudeOAuthToken(value: value, expiresAt: Date().addingTimeInterval(3600)) + } + } + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: nil, load: load("token-a"))?.value, "token-a") + XCTAssertEqual(reads, 1) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + ClaudeOAuthTokenReader.invalidate() + } } diff --git a/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift b/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift index 28638ce..5e74562 100644 --- a/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift +++ b/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift @@ -128,9 +128,34 @@ final class HistoryPaceStoreTests: XCTestCase { XCTAssertTrue(try UsageHistoryStore.paceRows(at: database).isEmpty) } + func testPaceReadsOnlyReturnRowsOfTheCurrentAccount() throws { + try record(0, 10) + try record(15, 12) + try record(30, 14) + for (minute, used) in [(45, 20.0), (60, 24), (75, 28)] { + try record(Double(minute), used, fingerprint: "account-a") + } + + let legacyRows = try UsageHistoryStore.paceRows(at: database) + let accountRows = try UsageHistoryStore.paceRows(accountFingerprint: "account-a", at: database) + XCTAssertFalse(legacyRows.isEmpty) + XCTAssertFalse(accountRows.isEmpty) + XCTAssertTrue(legacyRows.allSatisfy { $0.pace.sampledAt <= instant(30) }) + XCTAssertTrue(accountRows.allSatisfy { $0.pace.sampledAt > instant(30) }) + XCTAssertTrue(try UsageHistoryStore.paceRows(accountFingerprint: "account-b", at: database).isEmpty) + XCTAssertTrue( + try UsageHistoryStore.recentPaces(for: [key], accountFingerprint: "account-b", at: database).isEmpty) + XCTAssertEqual( + try UsageHistoryStore.recentPaces(for: [key], accountFingerprint: "account-a", at: database) + .map { $0.pace.sampledAt }, + accountRows.map { $0.pace.sampledAt }.reversed()) + } + private func instant(_ minutes: Double) -> Date { base.addingTimeInterval(minutes * 60) } - private func record(_ minutes: Double, _ used: Double, jitter: Double = 0, archive: Bool = true) throws { + private func record( + _ minutes: Double, _ used: Double, jitter: Double = 0, archive: Bool = true, fingerprint: String? = nil + ) throws { let snapshot = ProviderUsageSnapshot( provider: .claude, windows: [ @@ -139,6 +164,7 @@ final class HistoryPaceStoreTests: XCTestCase { durationMinutes: 10_080, displayName: "Fable") ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: instant(minutes), activityReadSucceeded: false) - try UsageHistoryStore.record(snapshot, at: database, now: instant(minutes), recordQuota: archive) + try UsageHistoryStore.record( + snapshot, at: database, now: instant(minutes), recordQuota: archive, accountFingerprint: fingerprint) } } diff --git a/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift b/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift index 84a882c..d318848 100644 --- a/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift +++ b/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift @@ -77,9 +77,51 @@ final class QuotaContinuityTests: XCTestCase { XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(0)) } + func testDifferentAccountFingerprintsNeverFormOnePace() throws { + try record(0, used: 10, fingerprint: "account-a") + try record(15, used: 11, fingerprint: "account-a") + try record(30, used: 12, fingerprint: "account-a") + XCTAssertNotNil(try pace(at: 30)) + + try record(45, used: 20, fingerprint: "account-b") + try record(60, used: 21, fingerprint: "account-b") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNil(try pace(at: 60)) + + try record(75, used: 22, fingerprint: "account-b") + XCTAssertNotNil(try pace(at: 75)) + } + + func testLegacyRowsBreakOnceWhenAFingerprintAppearsAndThenRebuild() throws { + try record(0, used: 10) + try record(15, used: 11) + try record(30, used: 12) + XCTAssertNotNil(try pace(at: 30)) + + try record(45, used: 13, fingerprint: "account-a") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNil(try pace(at: 45)) + + try record(60, used: 14, fingerprint: "account-a") + try record(75, used: 15, fingerprint: "account-a") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNotNil(try pace(at: 75)) + } + + func testAlwaysUnknownIdentityKeepsOneContinuousPace() throws { + try record(0, used: 10) + try record(15, used: 11) + try record(30, used: 12) + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(0)) + let result = try XCTUnwrap(pace(at: 30)) + XCTAssertEqual(result.observedMinutes, 30) + } + private func instant(_ minutes: Double) -> Date { base.addingTimeInterval(minutes * 60) } - private func record(_ minutes: Double, used: Double, reset: Date? = nil) throws { + private func record( + _ minutes: Double, used: Double, reset: Date? = nil, fingerprint: String? = nil + ) throws { let snapshot = ProviderUsageSnapshot( provider: .claude, windows: [ @@ -89,7 +131,8 @@ final class QuotaContinuityTests: XCTestCase { ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: instant(minutes), activityReadSucceeded: false) - try UsageHistoryStore.record(snapshot, at: database, now: instant(minutes)) + try UsageHistoryStore.record( + snapshot, at: database, now: instant(minutes), accountFingerprint: fingerprint) } private func pace(at minutes: Double) throws -> QuotaPace? {