Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthPreflightGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import Foundation

#if os(macOS)
import os.lock

enum ClaudeCLIAuthPreflightGate {
private struct State {
var loaded = false
var blockedUntil: Date?
}

private static let lock = OSAllocatedUnfairLock<State>(initialState: State())
private static let blockedUntilKey = "claudeCLIAuthPreflightBlockedUntilV1"
private static let timeoutCooldown: TimeInterval = 60 * 15
private static let failureCooldown: TimeInterval = 60 * 15
private static let log = CodexBarLog.logger(LogCategories.claudeCLI)

#if DEBUG
final class BlockedUntilStore: @unchecked Sendable {
var blockedUntil: Date?

init(blockedUntil: Date? = nil) {
self.blockedUntil = blockedUntil
}
}

@TaskLocal private static var taskStoreOverrideForTesting: BlockedUntilStore?
#endif

static func blockedUntil(
interaction: ProviderInteraction = ProviderInteractionContext.current,
now: Date = Date()) -> Date?
{
guard interaction != .userInitiated else { return nil }
#if DEBUG
if let store = self.taskStoreOverrideForTesting {
return self.activeBlockedUntil(store.blockedUntil, now: now) { store.blockedUntil = $0 }
}
#endif
return self.lock.withLock { state in
self.loadIfNeeded(&state)
return self.activeBlockedUntil(state.blockedUntil, now: now) {
state.blockedUntil = $0
self.persist(state)
}
}
}

static func recordTimeout(now: Date = Date()) {
self.recordBlocked(until: now.addingTimeInterval(self.timeoutCooldown), reason: "timeout")
}

static func recordFailure(now: Date = Date()) {
self.recordBlocked(until: now.addingTimeInterval(self.failureCooldown), reason: "failure")
}

@discardableResult
static func clear(now: Date = Date()) -> Bool {
#if DEBUG
if let store = self.taskStoreOverrideForTesting {
let wasBlocked = store.blockedUntil.map { $0 > now } ?? false
store.blockedUntil = nil
return wasBlocked
}
#endif
return self.lock.withLock { state in
self.loadIfNeeded(&state)
let wasBlocked = state.blockedUntil.map { $0 > now } ?? false
guard state.blockedUntil != nil else { return false }
state.blockedUntil = nil
self.persist(state)
return wasBlocked
}
}

#if DEBUG
static func withBlockedUntilStoreOverrideForTesting<T>(
_ store: BlockedUntilStore?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$taskStoreOverrideForTesting.withValue(store) {
try await operation()
}
}

static func resetForTesting() {
self.lock.withLock { state in
state.loaded = true
state.blockedUntil = nil
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
}
}

static var blockedUntilKeyForTesting: String {
self.blockedUntilKey
}

static func reloadPersistedStateForTesting() {
self.lock.withLock { state in
state.loaded = false
state.blockedUntil = nil
}
}
#endif

private static func activeBlockedUntil(
_ blockedUntil: Date?,
now: Date,
update: (Date?) -> Void) -> Date?
{
guard let blockedUntil else { return nil }
guard blockedUntil > now else {
update(nil)
return nil
}
return blockedUntil
}

private static func recordBlocked(until: Date, reason: String) {
#if DEBUG
if let store = self.taskStoreOverrideForTesting {
store.blockedUntil = until
return
}
#endif
self.lock.withLock { state in
self.loadIfNeeded(&state)
state.blockedUntil = until
self.persist(state)
}
self.log.warning(
"Claude CLI background auth preflight paused",
metadata: [
"reason": reason,
"until": "\(until.timeIntervalSince1970)",
])
}

private static func loadIfNeeded(_ state: inout State) {
guard !state.loaded else { return }
state.loaded = true
if let raw = UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double {
state.blockedUntil = Date(timeIntervalSince1970: raw)
}
}

private static func persist(_ state: State) {
if let blockedUntil = state.blockedUntil {
UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.blockedUntilKey)
} else {
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
}
}
}
#else
enum ClaudeCLIAuthPreflightGate {
static func blockedUntil(
interaction _: ProviderInteraction = ProviderInteractionContext.current,
now _: Date = Date()) -> Date?
{
nil
}

static func recordTimeout(now _: Date = Date()) {}
static func recordFailure(now _: Date = Date()) {}

@discardableResult
static func clear(now _: Date = Date()) -> Bool {
false
}

#if DEBUG
static func resetForTesting() {}
#endif
}
#endif
Original file line number Diff line number Diff line change
@@ -1,14 +1,77 @@
import Foundation

enum ClaudeCLIAuthStatusProbe {
enum Outcome: Equatable, Sendable {
case loggedIn
case loggedOut
case timedOut
case cancelled
case failed
}

private struct Response: Decodable {
let loggedIn: Bool
}

static func isLoggedIn(
private struct Request: Hashable, Sendable {
let binary: String
let environment: [String: String]
let timeout: TimeInterval
}

private actor Coordinator {
private var inFlight: [Request: Task<Outcome, Never>] = [:]

func run(
request: Request,
operation: @escaping @Sendable () async -> Outcome) async -> Outcome
{
if let task = self.inFlight[request] {
return await task.value
}

let task = Task { await operation() }
self.inFlight[request] = task
let outcome = await task.value
self.inFlight[request] = nil
return outcome
}
}

private static let coordinator = Coordinator()

#if DEBUG
typealias ProbeOverride = @Sendable (String, [String: String], TimeInterval) async -> Outcome
@TaskLocal static var probeOverrideForTesting: ProbeOverride?
#endif

static func probe(
binary: String,
environment: [String: String],
timeout: TimeInterval = 5) async -> Outcome
{
guard !Task.isCancelled else { return .cancelled }
let request = Request(binary: binary, environment: environment, timeout: timeout)
#if DEBUG
let override = self.probeOverrideForTesting
#endif
// Keep the one bounded subprocess alive when a waiter is cancelled so other concurrent refreshes can share
// its result. The cancelled waiter still receives `.cancelled` after the shared probe finishes.
let outcome = await self.coordinator.run(request: request) {
#if DEBUG
if let override {
return await override(binary, environment, timeout)
}
#endif
return await self.runProbe(binary: binary, environment: environment, timeout: timeout)
}
return Task.isCancelled ? .cancelled : outcome
}

private static func runProbe(
binary: String,
environment: [String: String],
timeout: TimeInterval = 5) async -> Bool
timeout: TimeInterval) async -> Outcome
{
do {
let result = try await SubprocessRunner.run(
Expand All @@ -18,18 +81,26 @@ enum ClaudeCLIAuthStatusProbe {
timeout: timeout,
standardInput: FileHandle.nullDevice,
label: "claude-auth-status")
return self.parseLoggedIn(result.stdout)
guard let response = self.parseResponse(result.stdout) else { return .failed }
return response.loggedIn ? .loggedIn : .loggedOut
} catch is CancellationError {
return .cancelled
} catch let error as SubprocessRunnerError {
if case .timedOut = error {
return .timedOut
}
return .failed
} catch {
return false
return .failed
}
}

static func parseLoggedIn(_ output: String) -> Bool {
guard let data = output.data(using: .utf8),
let response = try? JSONDecoder().decode(Response.self, from: data)
else {
return false
}
return response.loggedIn
self.parseResponse(output)?.loggedIn == true
}

private static func parseResponse(_ output: String) -> Response? {
guard let data = output.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(Response.self, from: data)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,8 @@ public enum ClaudeWebFetchStrategyError: LocalizedError, Equatable, Sendable {
}

struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
private static let log = CodexBarLog.logger(LogCategories.claudeCLI)

let id: String = "claude.cli"
let kind: ProviderFetchKind = .cli
let useWebExtras: Bool
Expand All @@ -643,13 +645,34 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
// The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and background
// app Auto refreshes must establish authentication through the non-interactive status command first.
let requiresAuthPreflight = context.runtime == .cli || (
context.runtime == .app &&
context.sourceMode == .auto &&
ProviderInteractionContext.current == .background)
let isBackgroundAppAuto = context.runtime == .app &&
context.sourceMode == .auto &&
ProviderInteractionContext.current == .background
let requiresAuthPreflight = context.runtime == .cli || isBackgroundAppAuto
guard requiresAuthPreflight else { return true }
if isBackgroundAppAuto,
let blockedUntil = ClaudeCLIAuthPreflightGate.blockedUntil()
{
Self.log.debug(
"Claude CLI background auth preflight skipped by cooldown",
metadata: ["until": "\(blockedUntil.timeIntervalSince1970)"])
return false
}
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env)
let outcome = await ClaudeCLIAuthStatusProbe.probe(binary: binary, environment: context.env)
if isBackgroundAppAuto {
switch outcome {
case .loggedIn:
ClaudeCLIAuthPreflightGate.clear()
case .timedOut:
ClaudeCLIAuthPreflightGate.recordTimeout()
case .failed:
ClaudeCLIAuthPreflightGate.recordFailure()
case .loggedOut, .cancelled:
break
}
}
return outcome == .loggedIn
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
Expand All @@ -663,6 +686,9 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
webOrganizationID: context.settings?.claude?.organizationID,
keepCLISessionsAlive: keepAlive)
let usage = try await fetcher.loadLatestUsage(model: "sonnet")
if context.runtime == .app {
ClaudeCLIAuthPreflightGate.clear()
}
return self.makeResult(
usage: ClaudeOAuthFetchStrategy.snapshot(from: usage),
sourceLabel: "claude")
Expand Down
Loading