Skip to content

Commit dbe477e

Browse files
author
Alexander Hoffer
committed
Pause blocked Claude CLI auth probes
1 parent aea62e0 commit dbe477e

6 files changed

Lines changed: 544 additions & 67 deletions
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import Foundation
2+
3+
#if os(macOS)
4+
import os.lock
5+
6+
enum ClaudeCLIAuthPreflightGate {
7+
private struct State {
8+
var loaded = false
9+
var blockedUntil: Date?
10+
}
11+
12+
private static let lock = OSAllocatedUnfairLock<State>(initialState: State())
13+
private static let blockedUntilKey = "claudeCLIAuthPreflightBlockedUntilV1"
14+
private static let timeoutCooldown: TimeInterval = 60 * 15
15+
private static let failureCooldown: TimeInterval = 60 * 15
16+
private static let log = CodexBarLog.logger(LogCategories.claudeCLI)
17+
18+
#if DEBUG
19+
final class BlockedUntilStore: @unchecked Sendable {
20+
var blockedUntil: Date?
21+
22+
init(blockedUntil: Date? = nil) {
23+
self.blockedUntil = blockedUntil
24+
}
25+
}
26+
27+
@TaskLocal private static var taskStoreOverrideForTesting: BlockedUntilStore?
28+
#endif
29+
30+
static func blockedUntil(
31+
interaction: ProviderInteraction = ProviderInteractionContext.current,
32+
now: Date = Date()) -> Date?
33+
{
34+
guard interaction != .userInitiated else { return nil }
35+
#if DEBUG
36+
if let store = self.taskStoreOverrideForTesting {
37+
return self.activeBlockedUntil(store.blockedUntil, now: now) { store.blockedUntil = $0 }
38+
}
39+
#endif
40+
return self.lock.withLock { state in
41+
self.loadIfNeeded(&state)
42+
return self.activeBlockedUntil(state.blockedUntil, now: now) {
43+
state.blockedUntil = $0
44+
self.persist(state)
45+
}
46+
}
47+
}
48+
49+
static func recordTimeout(now: Date = Date()) {
50+
self.recordBlocked(until: now.addingTimeInterval(self.timeoutCooldown), reason: "timeout")
51+
}
52+
53+
static func recordFailure(now: Date = Date()) {
54+
self.recordBlocked(until: now.addingTimeInterval(self.failureCooldown), reason: "failure")
55+
}
56+
57+
@discardableResult
58+
static func clear(now: Date = Date()) -> Bool {
59+
#if DEBUG
60+
if let store = self.taskStoreOverrideForTesting {
61+
let wasBlocked = store.blockedUntil.map { $0 > now } ?? false
62+
store.blockedUntil = nil
63+
return wasBlocked
64+
}
65+
#endif
66+
return self.lock.withLock { state in
67+
self.loadIfNeeded(&state)
68+
let wasBlocked = state.blockedUntil.map { $0 > now } ?? false
69+
guard state.blockedUntil != nil else { return false }
70+
state.blockedUntil = nil
71+
self.persist(state)
72+
return wasBlocked
73+
}
74+
}
75+
76+
#if DEBUG
77+
static func withBlockedUntilStoreOverrideForTesting<T>(
78+
_ store: BlockedUntilStore?,
79+
operation: () async throws -> T) async rethrows -> T
80+
{
81+
try await self.$taskStoreOverrideForTesting.withValue(store) {
82+
try await operation()
83+
}
84+
}
85+
86+
static func resetForTesting() {
87+
self.lock.withLock { state in
88+
state.loaded = true
89+
state.blockedUntil = nil
90+
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
91+
}
92+
}
93+
94+
static var blockedUntilKeyForTesting: String {
95+
self.blockedUntilKey
96+
}
97+
98+
static func reloadPersistedStateForTesting() {
99+
self.lock.withLock { state in
100+
state.loaded = false
101+
state.blockedUntil = nil
102+
}
103+
}
104+
#endif
105+
106+
private static func activeBlockedUntil(
107+
_ blockedUntil: Date?,
108+
now: Date,
109+
update: (Date?) -> Void) -> Date?
110+
{
111+
guard let blockedUntil else { return nil }
112+
guard blockedUntil > now else {
113+
update(nil)
114+
return nil
115+
}
116+
return blockedUntil
117+
}
118+
119+
private static func recordBlocked(until: Date, reason: String) {
120+
#if DEBUG
121+
if let store = self.taskStoreOverrideForTesting {
122+
store.blockedUntil = until
123+
return
124+
}
125+
#endif
126+
self.lock.withLock { state in
127+
self.loadIfNeeded(&state)
128+
state.blockedUntil = until
129+
self.persist(state)
130+
}
131+
self.log.warning(
132+
"Claude CLI background auth preflight paused",
133+
metadata: [
134+
"reason": reason,
135+
"until": "\(until.timeIntervalSince1970)",
136+
])
137+
}
138+
139+
private static func loadIfNeeded(_ state: inout State) {
140+
guard !state.loaded else { return }
141+
state.loaded = true
142+
if let raw = UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double {
143+
state.blockedUntil = Date(timeIntervalSince1970: raw)
144+
}
145+
}
146+
147+
private static func persist(_ state: State) {
148+
if let blockedUntil = state.blockedUntil {
149+
UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.blockedUntilKey)
150+
} else {
151+
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
152+
}
153+
}
154+
}
155+
#else
156+
enum ClaudeCLIAuthPreflightGate {
157+
static func blockedUntil(
158+
interaction _: ProviderInteraction = ProviderInteractionContext.current,
159+
now _: Date = Date()) -> Date?
160+
{
161+
nil
162+
}
163+
164+
static func recordTimeout(now _: Date = Date()) {}
165+
static func recordFailure(now _: Date = Date()) {}
166+
167+
@discardableResult
168+
static func clear(now _: Date = Date()) -> Bool {
169+
false
170+
}
171+
172+
#if DEBUG
173+
static func resetForTesting() {}
174+
#endif
175+
}
176+
#endif
Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,77 @@
11
import Foundation
22

33
enum ClaudeCLIAuthStatusProbe {
4+
enum Outcome: Equatable, Sendable {
5+
case loggedIn
6+
case loggedOut
7+
case timedOut
8+
case cancelled
9+
case failed
10+
}
11+
412
private struct Response: Decodable {
513
let loggedIn: Bool
614
}
715

8-
static func isLoggedIn(
16+
private struct Request: Hashable, Sendable {
17+
let binary: String
18+
let environment: [String: String]
19+
let timeout: TimeInterval
20+
}
21+
22+
private actor Coordinator {
23+
private var inFlight: [Request: Task<Outcome, Never>] = [:]
24+
25+
func run(
26+
request: Request,
27+
operation: @escaping @Sendable () async -> Outcome) async -> Outcome
28+
{
29+
if let task = self.inFlight[request] {
30+
return await task.value
31+
}
32+
33+
let task = Task { await operation() }
34+
self.inFlight[request] = task
35+
let outcome = await task.value
36+
self.inFlight[request] = nil
37+
return outcome
38+
}
39+
}
40+
41+
private static let coordinator = Coordinator()
42+
43+
#if DEBUG
44+
typealias ProbeOverride = @Sendable (String, [String: String], TimeInterval) async -> Outcome
45+
@TaskLocal static var probeOverrideForTesting: ProbeOverride?
46+
#endif
47+
48+
static func probe(
49+
binary: String,
50+
environment: [String: String],
51+
timeout: TimeInterval = 5) async -> Outcome
52+
{
53+
guard !Task.isCancelled else { return .cancelled }
54+
let request = Request(binary: binary, environment: environment, timeout: timeout)
55+
#if DEBUG
56+
let override = self.probeOverrideForTesting
57+
#endif
58+
// Keep the one bounded subprocess alive when a waiter is cancelled so other concurrent refreshes can share
59+
// its result. The cancelled waiter still receives `.cancelled` after the shared probe finishes.
60+
let outcome = await self.coordinator.run(request: request) {
61+
#if DEBUG
62+
if let override {
63+
return await override(binary, environment, timeout)
64+
}
65+
#endif
66+
return await self.runProbe(binary: binary, environment: environment, timeout: timeout)
67+
}
68+
return Task.isCancelled ? .cancelled : outcome
69+
}
70+
71+
private static func runProbe(
972
binary: String,
1073
environment: [String: String],
11-
timeout: TimeInterval = 5) async -> Bool
74+
timeout: TimeInterval) async -> Outcome
1275
{
1376
do {
1477
let result = try await SubprocessRunner.run(
@@ -18,18 +81,26 @@ enum ClaudeCLIAuthStatusProbe {
1881
timeout: timeout,
1982
standardInput: FileHandle.nullDevice,
2083
label: "claude-auth-status")
21-
return self.parseLoggedIn(result.stdout)
84+
guard let response = self.parseResponse(result.stdout) else { return .failed }
85+
return response.loggedIn ? .loggedIn : .loggedOut
86+
} catch is CancellationError {
87+
return .cancelled
88+
} catch let error as SubprocessRunnerError {
89+
if case .timedOut = error {
90+
return .timedOut
91+
}
92+
return .failed
2293
} catch {
23-
return false
94+
return .failed
2495
}
2596
}
2697

2798
static func parseLoggedIn(_ output: String) -> Bool {
28-
guard let data = output.data(using: .utf8),
29-
let response = try? JSONDecoder().decode(Response.self, from: data)
30-
else {
31-
return false
32-
}
33-
return response.loggedIn
99+
self.parseResponse(output)?.loggedIn == true
100+
}
101+
102+
private static func parseResponse(_ output: String) -> Response? {
103+
guard let data = output.data(using: .utf8) else { return nil }
104+
return try? JSONDecoder().decode(Response.self, from: data)
34105
}
35106
}

Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,8 @@ public enum ClaudeWebFetchStrategyError: LocalizedError, Equatable, Sendable {
633633
}
634634

635635
struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
636+
private static let log = CodexBarLog.logger(LogCategories.claudeCLI)
637+
636638
let id: String = "claude.cli"
637639
let kind: ProviderFetchKind = .cli
638640
let useWebExtras: Bool
@@ -643,13 +645,34 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
643645
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
644646
// The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and background
645647
// app Auto refreshes must establish authentication through the non-interactive status command first.
646-
let requiresAuthPreflight = context.runtime == .cli || (
647-
context.runtime == .app &&
648-
context.sourceMode == .auto &&
649-
ProviderInteractionContext.current == .background)
648+
let isBackgroundAppAuto = context.runtime == .app &&
649+
context.sourceMode == .auto &&
650+
ProviderInteractionContext.current == .background
651+
let requiresAuthPreflight = context.runtime == .cli || isBackgroundAppAuto
650652
guard requiresAuthPreflight else { return true }
653+
if isBackgroundAppAuto,
654+
let blockedUntil = ClaudeCLIAuthPreflightGate.blockedUntil()
655+
{
656+
Self.log.debug(
657+
"Claude CLI background auth preflight skipped by cooldown",
658+
metadata: ["until": "\(blockedUntil.timeIntervalSince1970)"])
659+
return false
660+
}
651661
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
652-
return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env)
662+
let outcome = await ClaudeCLIAuthStatusProbe.probe(binary: binary, environment: context.env)
663+
if isBackgroundAppAuto {
664+
switch outcome {
665+
case .loggedIn:
666+
ClaudeCLIAuthPreflightGate.clear()
667+
case .timedOut:
668+
ClaudeCLIAuthPreflightGate.recordTimeout()
669+
case .failed:
670+
ClaudeCLIAuthPreflightGate.recordFailure()
671+
case .loggedOut, .cancelled:
672+
break
673+
}
674+
}
675+
return outcome == .loggedIn
653676
}
654677

655678
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
@@ -663,6 +686,9 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
663686
webOrganizationID: context.settings?.claude?.organizationID,
664687
keepCLISessionsAlive: keepAlive)
665688
let usage = try await fetcher.loadLatestUsage(model: "sonnet")
689+
if context.runtime == .app {
690+
ClaudeCLIAuthPreflightGate.clear()
691+
}
666692
return self.makeResult(
667693
usage: ClaudeOAuthFetchStrategy.snapshot(from: usage),
668694
sourceLabel: "claude")

0 commit comments

Comments
 (0)