diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 6a13a884..bbfb7a2e 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -6,10 +6,12 @@ import SweetCookieKit public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" private static let appGroupID = "group.com.steipete.codexbar" + @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? public nonisolated(unsafe) static var isDisabled: Bool { get { + if let taskOverrideValue { return taskOverrideValue } if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = UserDefaults(suiteName: Self.appGroupID), @@ -26,4 +28,10 @@ public enum KeychainAccessGate { #endif } } + + static func withTaskOverrideForTesting(_ disabled: Bool?, operation: () throws -> T) rethrows -> T { + try self.$taskOverrideValue.withValue(disabled) { + try operation() + } + } } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index a361c90e..9bb610ec 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -27,9 +27,15 @@ public enum KeychainCacheStore { private static let log = CodexBarLog.logger(LogCategories.keychainCache) private static let cacheService = "com.steipete.codexbar.cache" private static let cacheLabel = "CodexBar Cache" - private nonisolated(unsafe) static var serviceOverride: String? + private nonisolated(unsafe) static var globalServiceOverride: String? + @TaskLocal private static var serviceOverride: String? private static let testStoreLock = NSLock() - private nonisolated(unsafe) static var testStore: [Key: Data]? + private struct TestStoreKey: Hashable { + let service: String + let account: String + } + + private nonisolated(unsafe) static var testStore: [TestStoreKey: Data]? private nonisolated(unsafe) static var testStoreRefCount = 0 public static func load( @@ -132,7 +138,13 @@ public enum KeychainCacheStore { } static func setServiceOverrideForTesting(_ service: String?) { - self.serviceOverride = service + self.globalServiceOverride = service + } + + static func withServiceOverrideForTesting(_ service: String?, operation: () throws -> T) rethrows -> T { + try self.$serviceOverride.withValue(service) { + try operation() + } } static func setTestStoreForTesting(_ enabled: Bool) { @@ -152,7 +164,7 @@ public enum KeychainCacheStore { } private static var serviceName: String { - self.serviceOverride ?? self.cacheService + serviceOverride ?? self.globalServiceOverride ?? self.cacheService } private static func makeEncoder() -> JSONEncoder { @@ -174,7 +186,8 @@ public enum KeychainCacheStore { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } guard let store = self.testStore else { return nil } - guard let data = store[key] else { return .missing } + let testKey = TestStoreKey(service: self.serviceName, account: key.account) + guard let data = store[testKey] else { return .missing } let decoder = Self.makeDecoder() guard let decoded = try? decoder.decode(Entry.self, from: data) else { return .invalid @@ -188,7 +201,8 @@ public enum KeychainCacheStore { guard var store = self.testStore else { return false } let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { return true } - store[key] = data + let testKey = TestStoreKey(service: self.serviceName, account: key.account) + store[testKey] = data self.testStore = store return true } @@ -197,7 +211,8 @@ public enum KeychainCacheStore { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } guard var store = self.testStore else { return false } - store.removeValue(forKey: key) + let testKey = TestStoreKey(service: self.serviceName, account: key.account) + store.removeValue(forKey: testKey) self.testStore = store return true } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index f303bc43..9ae1bc05 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -11,12 +11,14 @@ actor ClaudeCLISession { enum SessionError: LocalizedError { case launchFailed(String) + case ioFailed(String) case timedOut case processExited var errorDescription: String? { switch self { case let .launchFailed(msg): "Failed to launch Claude CLI session: \(msg)" + case let .ioFailed(msg): "Claude CLI PTY I/O failed: \(msg)" case .timedOut: "Claude CLI session timed out." case .processExited: "Claude CLI session exited." } @@ -31,12 +33,12 @@ actor ClaudeCLISession { private var binaryPath: String? private var startedAt: Date? - private let sendOnSubstrings: [String: String] = [ + private let promptSends: [String: String] = [ "Do you trust the files in this folder?": "y\r", + "Quick safety check:": "\r", + "Yes, I trust this folder": "\r", "Ready to code here?": "\r", "Press Enter to continue": "\r", - "Show plan usage limits": "\r", - "Show Claude Code status": "\r", ] private struct RollingBuffer { @@ -66,6 +68,30 @@ actor ClaudeCLISession { } } + private static func normalizedNeedle(_ text: String) -> String { + String(text.lowercased().filter { !$0.isWhitespace }) + } + + private static func commandPaletteSends(for subcommand: String) -> [String: String] { + let normalized = subcommand.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "/usage": + // Claude's command palette can render several "Show ..." actions together; only auto-confirm the + // usage-related actions here so we do not accidentally execute /status. + return [ + "Show plan": "\r", + "Show plan usage limits": "\r", + ] + case "/status": + return [ + "Show Claude Code": "\r", + "Show Claude Code status": "\r", + ] + default: + return [:] + } + } + func capture( subcommand: String, binary: String, @@ -78,8 +104,10 @@ actor ClaudeCLISession { try self.ensureStarted(binary: binary) if let startedAt { let sinceStart = Date().timeIntervalSince(startedAt) - if sinceStart < 0.4 { - let delay = UInt64((0.4 - sinceStart) * 1_000_000_000) + // Claude's TUI can drop early keystrokes while it's still initializing. Wait a bit longer than the + // original 0.4s to ensure slash commands reliably open their panels. + if sinceStart < 2.0 { + let delay = UInt64((2.0 - sinceStart) * 1_000_000_000) try await Task.sleep(nanoseconds: delay) } } @@ -91,66 +119,72 @@ actor ClaudeCLISession { try self.send("\r") } - let stopNeedles = stopOnSubstrings.map { Data($0.utf8) } - let sendNeedles = self.sendOnSubstrings.map { (needle: Data($0.key.utf8), keys: Data($0.value.utf8)) } + let stopNeedles = stopOnSubstrings.map { Self.normalizedNeedle($0) } + var sendMap = self.promptSends + for (needle, keys) in Self.commandPaletteSends(for: trimmed) { + sendMap[needle] = keys + } + let sendNeedles = sendMap.map { (needle: Self.normalizedNeedle($0.key), keys: $0.value) } let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E]) let needleLengths = - stopNeedles.map(\.count) + - sendNeedles.map(\.needle.count) + + stopOnSubstrings.map(\.utf8.count) + + sendMap.keys.map(\.utf8.count) + [cursorQuery.count] let maxNeedle = needleLengths.max() ?? cursorQuery.count var scanBuffer = RollingBuffer(maxNeedle: maxNeedle) - var triggeredSends = Set() + var triggeredSends = Set() var buffer = Data() + var scanTailText = "" + var utf8Carry = Data() let deadline = Date().addingTimeInterval(timeout) var lastOutputAt = Date() var lastEnterAt = Date() - var nextCursorCheckAt = Date(timeIntervalSince1970: 0) var stoppedEarly = false + // Only send periodic Enter when the caller explicitly asks for it (used for /usage rendering). + // For /status, periodic input can keep producing output and prevent idle-timeout short-circuiting. + let effectiveEnterEvery: TimeInterval? = sendEnterEvery while Date() < deadline { let newData = self.readChunk() if !newData.isEmpty { buffer.append(newData) lastOutputAt = Date() + Self.appendScanText(newData: newData, scanTailText: &scanTailText, utf8Carry: &utf8Carry) + if scanTailText.count > 8192 { scanTailText = String(scanTailText.suffix(8192)) } } let scanData = scanBuffer.append(newData) - if Date() >= nextCursorCheckAt, - !scanData.isEmpty, + if !scanData.isEmpty, scanData.range(of: cursorQuery) != nil { try? self.send("\u{1b}[1;1R") - nextCursorCheckAt = Date().addingTimeInterval(1.0) } - if !sendNeedles.isEmpty { - for item in sendNeedles where !triggeredSends.contains(item.needle) { - if scanData.range(of: item.needle) != nil { - try? self.primaryHandle?.write(contentsOf: item.keys) - triggeredSends.insert(item.needle) - } + let normalizedScan = Self.normalizedNeedle(TextParsing.stripANSICodes(scanTailText)) + + for item in sendNeedles where !triggeredSends.contains(item.needle) { + if normalizedScan.contains(item.needle) { + try? self.send(item.keys) + triggeredSends.insert(item.needle) } } - if !stopNeedles.isEmpty, stopNeedles.contains(where: { scanData.range(of: $0) != nil }) { + if stopNeedles.contains(where: normalizedScan.contains) { stoppedEarly = true break } - if let idleTimeout, - !buffer.isEmpty, - Date().timeIntervalSince(lastOutputAt) >= idleTimeout + if self.shouldStopForIdleTimeout( + idleTimeout: idleTimeout, + bufferIsEmpty: buffer.isEmpty, + lastOutputAt: lastOutputAt) { stoppedEarly = true break } - if let every = sendEnterEvery, Date().timeIntervalSince(lastEnterAt) >= every { - try? self.send("\r") - lastEnterAt = Date() - } + self.sendPeriodicEnterIfNeeded(every: effectiveEnterEvery, lastEnterAt: &lastEnterAt) if let proc = self.process, !proc.isRunning { throw SessionError.processExited @@ -177,6 +211,33 @@ actor ClaudeCLISession { return text } + private static func appendScanText(newData: Data, scanTailText: inout String, utf8Carry: inout Data) { + // PTY reads can split multibyte UTF-8 sequences. Keep a small carry buffer so prompt/stop scanning doesn't + // drop chunks when the decode fails due to an incomplete trailing sequence. + var combined = Data() + combined.reserveCapacity(utf8Carry.count + newData.count) + combined.append(utf8Carry) + combined.append(newData) + + if let chunk = String(data: combined, encoding: .utf8) { + scanTailText.append(chunk) + utf8Carry.removeAll(keepingCapacity: true) + return + } + + for trimCount in 1...3 where combined.count > trimCount { + let prefix = combined.dropLast(trimCount) + if let chunk = String(data: prefix, encoding: .utf8) { + scanTailText.append(chunk) + utf8Carry = Data(combined.suffix(trimCount)) + return + } + } + + // If the data is still not UTF-8 decodable, keep only a small suffix to avoid unbounded growth. + utf8Carry = Data(combined.suffix(12)) + } + func reset() { self.cleanup() } @@ -202,7 +263,9 @@ actor ClaudeCLISession { let proc = Process() let resolvedURL = URL(fileURLWithPath: binary) - if resolvedURL.lastPathComponent == "claude", + let disableWatchdog = ProcessInfo.processInfo.environment["CODEXBAR_DISABLE_CLAUDE_WATCHDOG"] == "1" + if !disableWatchdog, + resolvedURL.lastPathComponent == "claude", let watchdog = TTYCommandRunner.locateBundledHelper("CodexBarClaudeWatchdog") { proc.executableURL = URL(fileURLWithPath: watchdog) @@ -268,8 +331,8 @@ actor ClaudeCLISession { if self.process != nil { Self.log.debug("Claude CLI session stopping") } - if let proc = self.process, proc.isRunning, let handle = self.primaryHandle { - try? handle.write(contentsOf: Data("/exit\n".utf8)) + if let proc = self.process, proc.isRunning { + try? self.writeAllToPrimary(Data("/exit\r".utf8)) } try? self.primaryHandle?.close() try? self.secondaryHandle?.close() @@ -320,9 +383,53 @@ actor ClaudeCLISession { _ = self.readChunk() } + private func shouldStopForIdleTimeout( + idleTimeout: TimeInterval?, + bufferIsEmpty: Bool, + lastOutputAt: Date) -> Bool + { + guard let idleTimeout, !bufferIsEmpty else { return false } + return Date().timeIntervalSince(lastOutputAt) >= idleTimeout + } + + private func sendPeriodicEnterIfNeeded(every: TimeInterval?, lastEnterAt: inout Date) { + guard let every, Date().timeIntervalSince(lastEnterAt) >= every else { return } + try? self.send("\r") + lastEnterAt = Date() + } + private func send(_ text: String) throws { guard let data = text.data(using: .utf8) else { return } - guard let handle = self.primaryHandle else { throw SessionError.processExited } - try handle.write(contentsOf: data) + guard self.primaryFD >= 0 else { throw SessionError.processExited } + try self.writeAllToPrimary(data) + } + + private func writeAllToPrimary(_ data: Data) throws { + guard self.primaryFD >= 0 else { throw SessionError.processExited } + try data.withUnsafeBytes { rawBytes in + guard let baseAddress = rawBytes.baseAddress else { return } + var offset = 0 + var retries = 0 + while offset < rawBytes.count { + let written = write(self.primaryFD, baseAddress.advanced(by: offset), rawBytes.count - offset) + if written > 0 { + offset += written + retries = 0 + continue + } + if written == 0 { break } + + let err = errno + if err == EINTR || err == EAGAIN || err == EWOULDBLOCK { + retries += 1 + if retries > 200 { + throw SessionError.ioFailed("write to PTY would block") + } + usleep(5000) + continue + } + throw SessionError.ioFailed("write to PTY failed: \(String(cString: strerror(err)))") + } + } } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index e4602674..4853a6cb 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -68,7 +68,11 @@ public struct ClaudeStatusProbe: Sendable { let timeout = self.timeout let keepAlive = self.keepCLISessionsAlive do { - let usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) + var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) + 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)) + } let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) let snap = try Self.parse(text: usage, statusText: status) @@ -128,7 +132,12 @@ public struct ClaudeStatusProbe: Sendable { throw ClaudeStatusProbeError.parseFailed(usageError) } - let labelContext = LabelSearchContext(text: clean) + // Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status + // line + // with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last + // Settings/Usage panel when present. + let usagePanelText = self.trimToLatestUsagePanel(clean) ?? clean + let labelContext = LabelSearchContext(text: usagePanelText) var sessionPct = self.extractPercent(labelSubstring: "Current session", context: labelContext) var weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", context: labelContext) @@ -143,11 +152,14 @@ public struct ClaudeStatusProbe: Sendable { // Fallback: order-based percent scraping when labels are present but the surrounding layout moved. // Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. - let hasWeeklyLabel = labelContext.contains(Self.weeklyLabelNeedle) + let compactContext = usagePanelText.lowercased().filter { !$0.isWhitespace } + let hasWeeklyLabel = + labelContext.contains(Self.weeklyLabelNeedle) + || compactContext.contains("currentweek") let hasOpusLabel = labelContext.contains(Self.opusLabelNeedle) || labelContext.contains(Self.sonnetLabelNeedle) if sessionPct == nil || (hasWeeklyLabel && weeklyPct == nil) || (hasOpusLabel && opusPct == nil) { - let ordered = self.allPercents(clean) + let ordered = self.allPercents(usagePanelText) if sessionPct == nil, ordered.indices.contains(0) { sessionPct = ordered[0] } if hasWeeklyLabel, weeklyPct == nil, ordered.indices.contains(1) { weeklyPct = ordered[1] } if hasOpusLabel, opusPct == nil, ordered.indices.contains(2) { opusPct = ordered[2] } @@ -161,7 +173,13 @@ public struct ClaudeStatusProbe: Sendable { reason: "missing session label", usage: clean, status: statusText) - throw ClaudeStatusProbeError.parseFailed("Missing Current session") + if shouldDump { + let tail = usagePanelText.suffix(1800) + let snippet = tail.isEmpty ? "(empty)" : String(tail) + throw ClaudeStatusProbeError.parseFailed( + "Missing Current session.\n\n--- Clean usage tail ---\n\(snippet)") + } + throw ClaudeStatusProbeError.parseFailed("Missing Current session.") } let sessionReset = self.extractReset(labelSubstring: "Current session", context: labelContext) @@ -223,6 +241,14 @@ public struct ClaudeStatusProbe: Sendable { return nil } + private static func usageOutputLooksRelevant(_ text: String) -> Bool { + let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } + return normalized.contains("currentsession") + || normalized.contains("currentweek") + || normalized.contains("loadingusage") + || normalized.contains("failedtoloadusagedata") + } + private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { for label in labelSubstrings { if let value = self.extractPercent(labelSubstring: label, context: context) { return value } @@ -231,6 +257,8 @@ public struct ClaudeStatusProbe: Sendable { } private static func percentFromLine(_ line: String, assumeRemainingWhenUnclear: Bool = false) -> Int? { + if self.isLikelyStatusContextLine(line) { return nil } + // Allow optional Unicode whitespace before % to handle CLI formatting changes. let pattern = #"([0-9]{1,3}(?:\.[0-9]+)?)\p{Zs}*%"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } @@ -253,6 +281,13 @@ public struct ClaudeStatusProbe: Sendable { return assumeRemainingWhenUnclear ? Int(clamped.rounded()) : nil } + private static func isLikelyStatusContextLine(_ line: String) -> Bool { + guard line.contains("|") else { return false } + let lower = line.lowercased() + let modelTokens = ["opus", "sonnet", "haiku", "default"] + return modelTokens.contains(where: lower.contains) + } + private static func extractFirst(pattern: String, text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(text.startIndex.. [Int] { let lines = text.components(separatedBy: .newlines) - return lines.compactMap { self.percentFromLine($0, assumeRemainingWhenUnclear: true) } + let normalized = text.lowercased().filter { !$0.isWhitespace } + let hasUsageWindows = normalized.contains("currentsession") || normalized.contains("currentweek") + let hasLoading = normalized.contains("loadingusage") + let hasUsagePercentKeywords = normalized.contains("used") || normalized.contains("left") + || normalized.contains("remaining") || normalized.contains("available") + let loadingOnly = hasLoading && !hasUsageWindows + guard hasUsageWindows || hasLoading else { return [] } + if loadingOnly { return [] } + guard hasUsagePercentKeywords else { return [] } + + // Keep this strict to avoid matching Claude's status-line context meter (e.g. "0%") as session usage when the + // /usage panel is still rendering. + return lines.compactMap { self.percentFromLine($0, assumeRemainingWhenUnclear: false) } + } + + /// Attempts to isolate the most recent /usage panel output from a PTY capture. + /// The Claude TUI draws a "Settings: … Usage …" header; we slice from its last occurrence to avoid earlier screen + /// fragments (like the status bar) contaminating percent scraping. + private static func trimToLatestUsagePanel(_ text: String) -> String? { + guard let settingsRange = text.range(of: "Settings:", options: [.caseInsensitive, .backwards]) else { + return nil + } + let tail = text[settingsRange.lowerBound...] + guard tail.range(of: "Usage", options: .caseInsensitive) != nil else { return nil } + let lower = tail.lowercased() + let hasPercent = lower.contains("%") + let hasUsageWords = lower.contains("used") || lower.contains("left") || lower.contains("remaining") + || lower.contains("available") + let hasLoading = lower.contains("loading usage") + guard (hasPercent && hasUsageWords) || hasLoading else { return nil } + return String(tail) } private static func extractReset(labelSubstring: String, context: LabelSearchContext) -> String? { @@ -641,6 +706,10 @@ public struct ClaudeStatusProbe: Sendable { private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { let stopOnSubstrings = subcommand == "/usage" ? [ + "Current week (all models)", + "Current week (Opus)", + "Current week (Sonnet only)", + "Current week (Sonnet)", "Current session", "Failed to load usage data", "failed to load usage data", diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 3e0b3923..47b2b09d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -24,44 +24,42 @@ struct ClaudeOAuthCredentialsStoreTests { @Test func loadsFromKeychainCacheBeforeExpiredFile() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - let previousGate = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previousGate } - - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(fileURL) - defer { ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(nil) } - - let expiredData = self.makeCredentialsData( - accessToken: "expired", - expiresAt: Date(timeIntervalSinceNow: -3600)) - try expiredData.write(to: fileURL) - - let cachedData = self.makeCredentialsData( - accessToken: "cached", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - ClaudeOAuthCredentialsStore.invalidateCache() - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - defer { KeychainCacheStore.clear(key: cacheKey) } - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) - // Re-store to cache after file check has marked file as "seen" - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) + defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(fileURL) + defer { ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(nil) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired", + expiresAt: Date(timeIntervalSinceNow: -3600)) + try expiredData.write(to: fileURL) + + let cachedData = self.makeCredentialsData( + accessToken: "cached", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + ClaudeOAuthCredentialsStore.invalidateCache() + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + defer { KeychainCacheStore.clear(key: cacheKey) } + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) + // Re-store to cache after file check has marked file as "seen" + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) - #expect(creds.accessToken == "cached") - #expect(creds.isExpired == false) + #expect(creds.accessToken == "cached") + #expect(creds.isExpired == false) + } } @Test @@ -104,33 +102,31 @@ struct ClaudeOAuthCredentialsStoreTests { @Test func returnsExpiredFileWhenNoOtherSources() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(fileURL) - defer { ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(nil) } - - let expiredData = self.makeCredentialsData( - accessToken: "expired-only", - expiresAt: Date(timeIntervalSinceNow: -3600)) - try expiredData.write(to: fileURL) + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(fileURL) + defer { ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(nil) } - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + let expiredData = self.makeCredentialsData( + accessToken: "expired-only", + expiresAt: Date(timeIntervalSinceNow: -3600)) + try expiredData.write(to: fileURL) - let previousGate = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previousGate } + ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) + defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } - ClaudeOAuthCredentialsStore.invalidateCache() - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + ClaudeOAuthCredentialsStore.invalidateCache() + let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) - #expect(creds.accessToken == "expired-only") - #expect(creds.isExpired == true) + #expect(creds.accessToken == "expired-only") + #expect(creds.isExpired == true) + } } @Test @@ -208,59 +204,62 @@ struct ClaudeOAuthCredentialsStoreTests { @Test func syncsCacheWhenClaudeKeychainFingerprintChangesAndTokenDiffers() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - } - - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cachedData = self.makeCredentialsData( - accessToken: "cached-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) - - let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "ref1") - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint1) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cachedData = self.makeCredentialsData( + accessToken: "cached-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date())) + + let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint1) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(cachedData) - let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(first.accessToken == "cached-token") + let first = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(first.accessToken == "cached-token") - ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting() - let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2") - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint2) - - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) - #expect(second.accessToken == "keychain-token") - - switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { - case let .found(entry): - let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) - #expect(parsed.accessToken == "keychain-token") - default: - #expect(Bool(false)) + let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2") + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(fingerprint2) + + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) + + let second = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + #expect(second.accessToken == "keychain-token") + + switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) { + case let .found(entry): + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "keychain-token") + default: + #expect(Bool(false)) + } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index f5472b17..281e4782 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -6,48 +6,43 @@ import Testing struct ClaudeOAuthKeychainAccessGateTests { @Test func blocksUntilCooldownExpires() { - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } - - let previousGate = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = false - defer { KeychainAccessGate.isDisabled = previousGate } - - let now = Date(timeIntervalSince1970: 1000) - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)) - - ClaudeOAuthKeychainAccessGate.recordDenied(now: now) - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false) - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1))) + KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1000) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)) + + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false) + #expect( + ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1))) + } } @Test func persistsDeniedUntil() { - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } - let previousGate = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = false - defer { KeychainAccessGate.isDisabled = previousGate } + let now = Date(timeIntervalSince1970: 2000) + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) - let now = Date(timeIntervalSince1970: 2000) - ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + ClaudeOAuthKeychainAccessGate.resetInMemoryForTesting() - ClaudeOAuthKeychainAccessGate.resetInMemoryForTesting() - - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) + #expect( + ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) + } } @Test func respectsDebugDisableKeychainAccess() { - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } - - let previous = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previous } - - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: Date()) == false) + KeychainAccessGate.withTaskOverrideForTesting(true) { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: Date()) == false) + } } } diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 8d283496..c8e375a4 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -273,35 +273,32 @@ struct KimiUsageSnapshotConversionTests { struct KimiTokenResolverTests { @Test func resolvesTokenFromEnvironment() { - let previous = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previous } - let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] - let token = ProviderTokenResolver.kimiAuthToken(environment: env) - #expect(token == "test.jwt.token") + KeychainAccessGate.withTaskOverrideForTesting(true) { + let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] + let token = ProviderTokenResolver.kimiAuthToken(environment: env) + #expect(token == "test.jwt.token") + } } @Test func resolvesTokenFromKeychainFirst() { // This test would require mocking the keychain. - let previous = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previous } - let env = ["KIMI_AUTH_TOKEN": "test.env.token"] - let token = ProviderTokenResolver.kimiAuthToken(environment: env) - #expect(token == "test.env.token") + KeychainAccessGate.withTaskOverrideForTesting(true) { + let env = ["KIMI_AUTH_TOKEN": "test.env.token"] + let token = ProviderTokenResolver.kimiAuthToken(environment: env) + #expect(token == "test.env.token") + } } @Test func resolutionIncludesSource() { - let previous = KeychainAccessGate.isDisabled - KeychainAccessGate.isDisabled = true - defer { KeychainAccessGate.isDisabled = previous } - let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] - let resolution = ProviderTokenResolver.kimiAuthResolution(environment: env) + KeychainAccessGate.withTaskOverrideForTesting(true) { + let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] + let resolution = ProviderTokenResolver.kimiAuthResolution(environment: env) - #expect(resolution?.token == "test.jwt.token") - #expect(resolution?.source == .environment) + #expect(resolution?.token == "test.jwt.token") + #expect(resolution?.source == .environment) + } } } diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index a0642700..78069992 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -224,6 +224,124 @@ struct StatusProbeTests { #expect(snap.secondaryResetDescription == "Resets Jan 2, 2026, 10:59pm (Europe/Helsinki)") } + @Test + func parseClaudeStatus_ignoresStatusBarContextPercent() throws { + let sample = """ + Claude Code v2.1.29 + 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code + + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + + Curretsession + ███████▌15%used + Resets 11:30pm (Asia/Calcutta) + + Current week (all models) + █▌ 3% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + + Current week (Sonnet only) + ▌ 1% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.sessionPercentLeft == 85) + #expect(snap.weeklyPercentLeft == 97) + #expect(snap.opusPercentLeft == 99) + } + + @Test + func parseClaudeStatus_loadingPanelDoesNotReportZeroPercent() { + let sample = """ + Claude Code v2.1.29 + 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code + + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail while /usage is still loading") + } catch ClaudeStatusProbeError.parseFailed { + return + } catch ClaudeStatusProbeError.timedOut { + return + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func parseClaudeStatus_statusOnlyOutputDoesNotFallbackToZero() { + let sample = """ + Claude Code v2.1.32 + 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left + Status: Partially Degraded Service + /status + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail when /usage windows are missing") + } catch ClaudeStatusProbeError.parseFailed { + return + } catch ClaudeStatusProbeError.timedOut { + return + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func parseClaudeStatus_placeholderUsageWindowDoesNotUseStatusBarPercent() { + let sample = """ + Claude Code v2.1.32 + 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left + Settings: Status Config Usage + Current session + Current week (all models) + Current week (Sonnet only) + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail when only status-bar percentages are present") + } catch ClaudeStatusProbeError.parseFailed { + return + } catch ClaudeStatusProbeError.timedOut { + return + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func parseClaudeStatus_compactMarkersStillParse() throws { + let sample = """ + Settings:StatusConfigUsage(←/→ortabtocycle) + Loadingusagedata… + Curretsession + ███6%used + Resets4:29am(Asia/Calcutta) + Currentweek(allmodels) + ██4%used + ResetsFeb12at1:29pm(Asia/Calcutta) + Currentweek(Sonnetonly) + ▌1%used + ResetsFeb12at1:29pm(Asia/Calcutta) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.sessionPercentLeft == 94) + #expect(snap.weeklyPercentLeft == 96) + #expect(snap.opusPercentLeft == 99) + } + @Test func parseClaudeStatusWithBracketPlanNoiseNoEsc() throws { let sample = """