diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..d1ebbcfe7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "mcp__plugin_github_github__issue_read", + "mcp__plugin_github_github__list_commits", + "mcp__plugin_github_github__get_commit", + "mcp__plugin_github_github__pull_request_read", + "mcp__plugin_github_github__get_file_contents", + "mcp__plugin_github_github__search_code", + "mcp__plugin_github_github__search_pull_requests", + "Bash(git log:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index c96e6eebf..d44f986f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Xcode user/session xcuserdata/ +.swiftpm/xcode/xcshareddata/ +.codexbar/config.json +*.env +*.local # Build products .build/ @@ -27,6 +31,7 @@ debug_*.swift .DS_Store .vscode/ .codex/environments/ +.swiftpm-cache/ # Debug/analysis docs docs/*-analysis.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bf6a172..122f164c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,58 @@ ## Unreleased ### Highlights -- Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, and make failure modes deterministic (#245, #305, #308, #309). Thanks @manikv12! +- Add an experimental option to suppress Claude Keychain prompts. +- Add OpenRouter provider for credit-based usage tracking (#396). Thanks @chountalas! +- Add Ollama provider, including token-account support in Settings and CLI (#380). Thanks @CryptoSageSnr! + + +### Providers & Usage +- OpenRouter: add credit tracking, key-quota popup support, token-account labels, fallback status icons, and updated icon/color (#396). Thanks @chountalas! +- Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! +- Codex: in percent display mode with "show remaining," show remaining credits in the menu bar when session or weekly usage is exhausted (#336). Thanks @teron131! +- Menu: rebuild the merged provider switcher when “Show usage as used” changes so switcher progress updates immediately (#306). Thanks @Flohhhhh! +- Update Kiro parsing for `kiro-cli` 1.24+ / Q Developer formats and non-managed plan handling (#288). Thanks @kilhyeonjun! +- OpenCode: treat explicit `null` subscription responses as missing usage data, skip POST fallback, and return a clearer workspace-specific error (#412). +- OpenCode: surface clearer HTTP errors. Thanks @SalimBinYousuf1! +- Warp: update API key setup guidance. +- Fix Claude setup message package name (#376). Thanks @daegwang! + +### Claude OAuth & Keychain +- Add an experimental Claude OAuth Security-CLI reader path and option in settings. +- Apply stored prompt mode and fallback policy to silent/noninteractive keychain probes. +- Add cooldown for background OAuth keychain retries. +- Disable experimental toggle when keychain access is disabled. + +### Dev & Tests +- Run provider fetches and Claude debug OAuth probes off `MainActor`. +- Split Claude OAuth test overrides and isolate coordinator tests. + + +## 0.18.0-beta.3 — 2026-02-13 +### Highlights +- Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12! - Claude: harden Claude Code PTY capture for `/usage` and `/status` (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320). -- Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234). Thanks @robinebers - and @theglove44! +- New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu! +- Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44! - Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs! - CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290). -### Claude OAuth & Keychain (upgrade-relevant behavior) -- Claude OAuth creds are cached in CodexBar Keychain. This reduces Keychain prompts until the token expires. -- If Claude OAuth credentials are present but expired, CodexBar performs at most one delegated refresh handoff to the Claude CLI and one OAuth retry before falling back to Web/CLI in Auto mode. -- Claude Auto mode keeps Keychain prompts suppressed during background refreshes. Interactive Keychain prompting is only attempted during user-initiated repair flows (e.g. menu open / manual refresh) when cached OAuth is missing/expired/unusable. -- Claude OAuth-only mode stays strict: OAuth failures do not silently fall back to Web/CLI. -- Keychain prompting is hardened (cooldowns after explicit denial/cancel/no-access + pre-alert only when interaction is likely) to reduce repeated prompts during refresh. -- CodexBar syncs its cached OAuth token when the Claude Code Keychain entry changes, so updated auth is picked up without requiring a restart. +### Claude OAuth & Keychain +- Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts. +- Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required. +- In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh). +- OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back. +- Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive. ### Provider & Usage Fixes +- Warp: add Warp provider support (credits + add-on credits), configurable via Settings or `WARP_API_KEY`/`WARP_TOKEN` (#352). Thanks @Kathie-yu! - Cursor: compute usage against `plan.limit` rather than `breakdown.total` to avoid incorrect limit interpretation (#240). Thanks @robinebers! - MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44! +- MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan! +- Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf! +- z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin! +- z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment). +- Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden! - Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev! - OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07! - Token-account precedence: selected token account env injection now correctly overrides provider config `apiKey` values in app and CLI environments. Thanks @arvindcr4! diff --git a/README.md b/README.md index 49d73d093..851e992d1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CodexBar 🎚️ - May your tokens never run out. (LARKIN FORK) Per steipete: -> Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +> Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 13544fba6..ce6992d45 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -201,12 +201,12 @@ if [[ -n "${RELEASE_ARCHES}" ]]; then ARCHES_VALUE="${RELEASE_ARCHES}" fi if [[ "${DEBUG_LLDB}" == "1" ]]; then - run_step "package app" env CODEXBAR_ALLOW_LLDB=1 ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/scripts/package_app.sh" debug + run_step "package app" env CODEXBAR_ALLOW_LLDB=1 ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" debug else if [[ -n "${SIGNING_MODE}" ]]; then - run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/scripts/package_app.sh" + run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" else - run_step "package app" env ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/scripts/package_app.sh" + run_step "package app" env ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" fi fi diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 98a2564cd..922de8ffd 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -210,6 +210,21 @@ build_product_path() { esac } +# Resolve path to built binary; some SwiftPM versions use .build/$CONF/ when building for host only. +resolve_binary_path() { + local name="$1" + local arch="$2" + local candidate + candidate=$(build_product_path "$name" "$arch") + if [[ -f "$candidate" ]]; then + echo "$candidate" + return + fi + if [[ "$arch" == "arm64" || "$arch" == "x86_64" ]] && [[ -f ".build/$CONF/$name" ]]; then + echo ".build/$CONF/$name" + fi +} + verify_binary_arches() { local binary="$1"; shift local expected=("$@") @@ -236,9 +251,9 @@ install_binary() { local binaries=() for arch in "${ARCH_LIST[@]}"; do local src - src=$(build_product_path "$name" "$arch") - if [[ ! -f "$src" ]]; then - echo "ERROR: Missing ${name} build for ${arch} at ${src}" >&2 + src=$(resolve_binary_path "$name" "$arch") + if [[ -z "$src" || ! -f "$src" ]]; then + echo "ERROR: Missing ${name} build for ${arch} at $(build_product_path "$name" "$arch")" >&2 exit 1 fi binaries+=("$src") @@ -254,14 +269,14 @@ install_binary() { install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. -if [[ -f "$(build_product_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then +if [[ -n "$(resolve_binary_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" fi # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. -if [[ -f "$(build_product_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then +if [[ -n "$(resolve_binary_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" fi -if [[ -f "$(build_product_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then +if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then WIDGET_APP="$APP/Contents/PlugIns/CodexBarWidget.appex" mkdir -p "$WIDGET_APP/Contents/MacOS" "$WIDGET_APP/Contents/Resources" cat > "$WIDGET_APP/Contents/Info.plist" < 0 + let hasWeekly = (bottomValue != nil) + let weeklyAvailable = hasWeekly && (bottomValue ?? 0) > 0 let creditsAlpha: CGFloat = 1.0 let topRectPx = RectPx(x: barXPx, y: 19, w: barWidthPx, h: 12) let bottomRectPx = RectPx(x: barXPx, y: 5, w: barWidthPx, h: 8) let creditsRectPx = RectPx(x: barXPx, y: 14, w: barWidthPx, h: 16) let creditsBottomRectPx = RectPx(x: barXPx, y: 4, w: barWidthPx, h: 6) + // Warp special case: when no bonus or bonus exhausted, show "top monthly, bottom dimmed" + let warpNoBonus = style == .warp && !weeklyAvailable + if weeklyAvailable { - // Normal: top=5h, bottom=weekly, no credits. + // Normal: top=primary, bottom=secondary (bonus/weekly). drawBar( rectPx: topRectPx, remaining: topValue, @@ -598,35 +665,48 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) - } else if !hasWeekly { - // Weekly missing (e.g. Claude enterprise): keep normal layout but - // dim the bottom track to indicate N/A. - if topValue == nil, let ratio = creditsRatio { - // Credits-only: show credits prominently (e.g. credits loaded before usage). - drawBar( - rectPx: creditsRectPx, - remaining: ratio, - alpha: creditsAlpha, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - blink: blink) - drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) - } else { + } else if !hasWeekly || warpNoBonus { + if style == .warp { + // Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track drawBar( rectPx: topRectPx, remaining: topValue, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, + addWarpTwist: true, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) + } else { + // Weekly missing (e.g. Claude enterprise): keep normal layout but + // dim the bottom track to indicate N/A. + if topValue == nil, let ratio = creditsRatio { + // Credits-only: show credits prominently (e.g. credits loaded before usage). + drawBar( + rectPx: creditsRectPx, + remaining: ratio, + alpha: creditsAlpha, + addNotches: style == .claude, + addFace: style == .codex, + addGeminiTwist: style == .gemini || style == .antigravity, + addAntigravityTwist: style == .antigravity, + addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, + blink: blink) + drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) + } else { + drawBar( + rectPx: topRectPx, + remaining: topValue, + addNotches: style == .claude, + addFace: style == .codex, + addGeminiTwist: style == .gemini || style == .antigravity, + addAntigravityTwist: style == .antigravity, + addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, + blink: blink) + drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) + } } } else { // Weekly exhausted/missing: show credits on top (thicker), weekly (likely 0) on bottom. @@ -640,6 +720,7 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) } else { // No credits available; fall back to 5h if present. @@ -651,6 +732,7 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) } drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 826e6844a..f9ef42b10 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -61,12 +61,14 @@ struct UsageMenuCardView: View { let spendLine: String } + let provider: UsageProvider let providerName: String let email: String let subtitleText: String let subtitleStyle: SubtitleStyle let planText: String? let metrics: [Metric] + let usageNotes: [String] let creditsText: String? let creditsRemaining: Double? let creditsHintText: String? @@ -81,6 +83,13 @@ struct UsageMenuCardView: View { let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted + static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { + if provider == .openrouter, metric.id == "primary" { + return "API key limit" + } + return metric.title + } + var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) @@ -90,13 +99,15 @@ struct UsageMenuCardView: View { } if self.model.metrics.isEmpty { - if let placeholder = self.model.placeholder { + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .font(.subheadline) } } else { - let hasUsage = !self.model.metrics.isEmpty + let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost @@ -107,8 +118,12 @@ struct UsageMenuCardView: View { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( metric: metric, + title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } } } if hasUsage, hasCredits || hasCost { @@ -172,7 +187,8 @@ struct UsageMenuCardView: View { } private var hasDetails: Bool { - !self.model.metrics.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil || + !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || + self.model.tokenUsage != nil || self.model.providerCost != nil } } @@ -305,12 +321,13 @@ private struct ProviderCostContent: View { private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric + let title: String let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 6) { - Text(self.metric.title) + Text(self.title) .font(.body) .fontWeight(.medium) UsageProgressBar( @@ -350,6 +367,7 @@ private struct MetricRow: View { } } } + .frame(maxWidth: .infinity, alignment: .leading) if let detail = self.metric.detailText { Text(detail) .font(.footnote) @@ -361,6 +379,24 @@ private struct MetricRow: View { } } +private struct UsageNotesContent: View { + let notes: [String] + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in + Text(note) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct UsageMenuCardHeaderSectionView: View { let model: UsageMenuCardView.Model let showDivider: Bool @@ -391,7 +427,9 @@ struct UsageMenuCardUsageSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { if self.model.metrics.isEmpty { - if let placeholder = self.model.placeholder { + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .font(.subheadline) @@ -400,8 +438,12 @@ struct UsageMenuCardUsageSectionView: View { ForEach(self.model.metrics, id: \.id) { metric in MetricRow( metric: metric, + title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } } if self.showBottomDivider { Divider() @@ -601,7 +643,10 @@ extension UsageMenuCardView.Model { account: input.account, metadata: input.metadata) let metrics = Self.metrics(input: input) - let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { + let usageNotes = Self.usageNotes(provider: input.provider, snapshot: input.snapshot) + let creditsText: String? = if input.provider == .openrouter { + nil + } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { nil } else { Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) @@ -624,12 +669,14 @@ extension UsageMenuCardView.Model { let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil return UsageMenuCardView.Model( + provider: input.provider, providerName: input.metadata.displayName, email: redacted.email, subtitleText: redacted.subtitleText, subtitleStyle: subtitle.style, planText: planText, metrics: metrics, + usageNotes: usageNotes, creditsText: creditsText, creditsRemaining: input.credits?.remaining, creditsHintText: redacted.creditsHintText, @@ -640,6 +687,23 @@ extension UsageMenuCardView.Model { progressColor: Self.progressColor(for: input.provider)) } + private static func usageNotes(provider: UsageProvider, snapshot: UsageSnapshot?) -> [String] { + guard provider == .openrouter, + let openRouter = snapshot?.openRouterUsage + else { + return [] + } + + switch openRouter.keyQuotaStatus { + case .available: + return [] + case .noLimitConfigured: + return ["No limit set for the API key"] + case .unavailable: + return ["API key limit unavailable right now"] + } + } + private static func email( for provider: UsageProvider, snapshot: UsageSnapshot?, @@ -742,7 +806,24 @@ extension UsageMenuCardView.Model { let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) + let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) if let primary = snapshot.primary { + var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil + var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) + if input.provider == .openrouter, + let openRouterQuotaDetail + { + primaryResetText = openRouterQuotaDetail + } + if input.provider == .warp, + let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + primaryDetailText = detail + } + if input.provider == .warp, primary.resetsAt == nil { + primaryResetText = nil + } let sessionPaceDetail = Self.sessionPaceDetail( provider: input.provider, window: primary, @@ -754,8 +835,8 @@ extension UsageMenuCardView.Model { percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now), - detailText: input.provider == .zai ? zaiTokenDetail : nil, + resetText: primaryResetText, + detailText: primaryDetailText, detailLeftText: sessionPaceDetail?.leftLabel, detailRightText: sessionPaceDetail?.rightLabel, pacePercent: sessionPaceDetail?.pacePercent, @@ -767,13 +848,22 @@ extension UsageMenuCardView.Model { window: weekly, now: input.now, showUsed: input.usageBarsShowUsed) + var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) + var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil + if input.provider == .warp, + let detail = weekly.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + weeklyResetText = nil + weeklyDetailText = detail + } metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now), - detailText: input.provider == .zai ? zaiTimeDetail : nil, + resetText: weeklyResetText, + detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, @@ -812,10 +902,33 @@ extension UsageMenuCardView.Model { private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { guard let limit else { return nil } - let currentStr = UsageFormatter.tokenCountString(limit.currentValue) - let usageStr = UsageFormatter.tokenCountString(limit.usage) - let remainingStr = UsageFormatter.tokenCountString(limit.remaining) - return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" + + if let currentValue = limit.currentValue, + let usage = limit.usage, + let remaining = limit.remaining + { + let currentStr = UsageFormatter.tokenCountString(currentValue) + let usageStr = UsageFormatter.tokenCountString(usage) + let remainingStr = UsageFormatter.tokenCountString(remaining) + return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" + } + + return nil + } + + private static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard provider == .openrouter, + let usage = snapshot.openRouterUsage, + usage.hasValidKeyQuota, + let keyRemaining = usage.keyRemaining, + let keyLimit = usage.keyLimit + else { + return nil + } + + let remaining = UsageFormatter.usdString(keyRemaining) + let limit = UsageFormatter.usdString(keyLimit) + return "\(remaining)/\(limit) left" } private struct PaceDetail { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 660a6ff01..a62984d4b 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -115,23 +115,46 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { + let primaryWindow = if provider == .warp { + // Warp primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits"). + // Avoid rendering it as a "Resets ..." line. + RateWindow( + usedPercent: primary.usedPercent, + windowMinutes: primary.windowMinutes, + resetsAt: primary.resetsAt, + resetDescription: nil) + } else { + primary + } Self.appendRateWindow( entries: &entries, title: meta.sessionLabel, - window: primary, + window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) + if provider == .warp, + let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + entries.append(.text(detail, .secondary)) + } if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) { entries.append(.text(paceSummary, .secondary)) } } if let weekly = snap.secondary { + let weeklyResetOverride: String? = { + guard provider == .warp else { return nil } + let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + return (detail?.isEmpty ?? true) ? nil : detail + }() Self.appendRateWindow( entries: &entries, title: meta.weeklyLabel, window: weekly, resetStyle: resetStyle, - showUsed: settings.usageBarsShowUsed) + showUsed: settings.usageBarsShowUsed, + resetOverride: weeklyResetOverride) if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) { entries.append(.text(paceSummary, .secondary)) } @@ -346,12 +369,15 @@ struct MenuDescriptor { title: String, window: RateWindow, resetStyle: ResetTimeDisplayStyle, - showUsed: Bool) + showUsed: Bool, + resetOverride: String? = nil) { let line = UsageFormatter .usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed) entries.append(.text("\(title): \(line)", .primary)) - if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { + if let resetOverride { + entries.append(.text(resetOverride, .secondary)) + } else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { entries.append(.text(reset, .secondary)) } } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index 1b8083406..c5d4730b4 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -106,6 +106,7 @@ struct DebugPane: View { Text("Cursor").tag(UsageProvider.cursor) Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) + Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) .frame(width: 460) @@ -299,6 +300,7 @@ struct DebugPane: View { Text("Antigravity").tag(UsageProvider.antigravity) Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) + Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) .frame(width: 360) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f8b843240..58a55deb5 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -17,6 +17,31 @@ struct ProviderDetailView: View { let onCopyError: (String) -> Void let onRefresh: () -> Void + static func metricTitle(provider: UsageProvider, metric: UsageMenuCardView.Model.Metric) -> String { + UsageMenuCardView.popupMetricTitle(provider: provider, metric: metric) + } + + static func planRow(provider: UsageProvider, planText: String?) -> (label: String, value: String)? { + guard let rawPlan = planText?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawPlan.isEmpty + else { + return nil + } + guard provider == .openrouter else { + return (label: "Plan", value: rawPlan) + } + + let prefix = "Balance:" + if rawPlan.hasPrefix(prefix) { + let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count) + let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedValue.isEmpty { + return (label: "Balance", value: trimmedValue) + } + } + return (label: "Balance", value: rawPlan) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -89,11 +114,13 @@ struct ProviderDetailView: View { if !self.model.email.isEmpty { infoLabels.append("Account") } - if let plan = self.model.planText, !plan.isEmpty { - infoLabels.append("Plan") + if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { + infoLabels.append(planRow.label) } - var metricLabels = self.model.metrics.map(\.title) + var metricLabels = self.model.metrics.map { metric in + Self.metricTitle(provider: self.provider, metric: metric) + } if self.model.creditsText != nil { metricLabels.append("Credits") } @@ -210,7 +237,6 @@ private struct ProviderDetailInfoGrid: View { let version = self.store.version(for: self.provider) ?? "not detected" let updated = self.updatedText let email = self.model.email - let plan = self.model.planText ?? "" let enabledText = self.isEnabled ? "Enabled" : "Disabled" Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { @@ -230,8 +256,8 @@ private struct ProviderDetailInfoGrid: View { ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) } - if !plan.isEmpty { - ProviderDetailInfoRow(label: "Plan", value: plan, labelWidth: self.labelWidth) + if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { + ProviderDetailInfoRow(label: planRow.label, value: planRow.value, labelWidth: self.labelWidth) } } .font(.footnote) @@ -272,15 +298,18 @@ struct ProviderMetricsInlineView: View { let labelWidth: CGFloat var body: some View { + let hasMetrics = !self.model.metrics.isEmpty + let hasUsageNotes = !self.model.usageNotes.isEmpty + let hasCredits = self.model.creditsText != nil + let hasProviderCost = self.model.providerCost != nil + let hasTokenUsage = self.model.tokenUsage != nil ProviderSettingsSection( title: "Usage", spacing: 8, verticalPadding: 6, horizontalPadding: 0) { - if self.model.metrics.isEmpty, self.model.providerCost == nil, - self.model.creditsText == nil, self.model.tokenUsage == nil - { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -288,10 +317,18 @@ struct ProviderMetricsInlineView: View { ForEach(self.model.metrics, id: \.id) { metric in ProviderMetricInlineRow( metric: metric, + title: ProviderDetailView.metricTitle(provider: self.provider, metric: metric), progressColor: self.model.progressColor, labelWidth: self.labelWidth) } + if hasUsageNotes { + ProviderUsageNotesInlineView( + notes: self.model.usageNotes, + labelWidth: self.labelWidth, + alignsWithMetricContent: hasMetrics) + } + if let credits = self.model.creditsText { ProviderMetricInlineTextRow( title: "Credits", @@ -330,12 +367,13 @@ struct ProviderMetricsInlineView: View { private struct ProviderMetricInlineRow: View { let metric: UsageMenuCardView.Model.Metric + let title: String let progressColor: Color let labelWidth: CGFloat var body: some View { HStack(alignment: .top, spacing: 10) { - Text(self.metric.title) + Text(self.title) .font(.subheadline.weight(.semibold)) .lineLimit(1) .frame(width: self.labelWidth, alignment: .leading) @@ -397,6 +435,32 @@ private struct ProviderMetricInlineRow: View { } } +private struct ProviderUsageNotesInlineView: View { + let notes: [String] + let labelWidth: CGFloat + let alignsWithMetricContent: Bool + + var body: some View { + HStack(alignment: .top, spacing: 10) { + if self.alignsWithMetricContent { + Spacer() + .frame(width: self.labelWidth) + } + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} + private struct ProviderMetricInlineTextRow: View { let title: String let value: String diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 5d7abde9a..414f41c55 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -98,6 +98,7 @@ struct ProviderSettingsPickerRowView: View { let picker: ProviderSettingsPickerDescriptor var body: some View { + let isEnabled = self.picker.isEnabled?() ?? true VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(self.picker.title) @@ -133,6 +134,7 @@ struct ProviderSettingsPickerRowView: View { .fixedSize(horizontal: false, vertical: true) } } + .disabled(!isEnabled) .onChange(of: self.picker.binding.wrappedValue) { _, selection in guard let onChange = self.picker.onChange else { return } Task { @MainActor in diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c08711077..877c78da9 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -264,21 +264,32 @@ struct ProvidersPane: View { func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { if provider == .zai { return nil } - let metadata = self.store.metadata(for: provider) - let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) - var options: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), - ] - if supportsAverage { - options.append(ProviderSettingsPickerOption( - id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + let options: [ProviderSettingsPickerOption] + if provider == .openrouter { + options = [ + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.primary.rawValue, + title: "Primary (API key limit)"), + ] + } else { + let metadata = self.store.metadata(for: provider) + let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) + var metricOptions: [ProviderSettingsPickerOption] = [ + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.primary.rawValue, + title: "Primary (\(metadata.sessionLabel))"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.secondary.rawValue, + title: "Secondary (\(metadata.weeklyLabel))"), + ] + if supportsAverage { + metricOptions.append(ProviderSettingsPickerOption( + id: MenuBarMetricPreference.average.rawValue, + title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + } + options = metricOptions } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 8a503fcbc..1e26c4c86 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -4,7 +4,8 @@ import Foundation struct ProviderSpec { let style: IconStyle let isEnabled: @MainActor () -> Bool - let fetch: () async -> ProviderFetchOutcome + let descriptor: ProviderDescriptor + let makeFetchContext: @MainActor () -> ProviderFetchContext } struct ProviderRegistry { @@ -33,22 +34,19 @@ struct ProviderRegistry { let spec = ProviderSpec( style: descriptor.branding.iconStyle, isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) }, - fetch: { + descriptor: descriptor, + makeFetchContext: { let sourceMode = ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) ?? .auto - let snapshot = await MainActor.run { - Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) - } - let env = await MainActor.run { - Self.makeEnvironment( - base: ProcessInfo.processInfo.environment, - provider: provider, - settings: settings, - tokenOverride: nil) - } + let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let env = Self.makeEnvironment( + base: ProcessInfo.processInfo.environment, + provider: provider, + settings: settings, + tokenOverride: nil) let verbose = settings.isVerboseLoggingEnabled - let context = ProviderFetchContext( + return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, @@ -60,7 +58,6 @@ struct ProviderRegistry { fetcher: codexFetcher, claudeFetcher: claudeFetcher, browserDetection: browserDetection) - return await descriptor.fetchOutcome(context: context) }) specs[provider] = spec } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 08147f3cc..de1bdffc7 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -23,6 +23,8 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeUsageDataSource _ = settings.claudeCookieSource _ = settings.claudeCookieHeader + _ = settings.claudeOAuthKeychainPromptMode + _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled } @@ -60,6 +62,36 @@ struct ClaudeProviderImplementation: ProviderImplementation { } } + @MainActor + func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { + let subtitle = if context.settings.debugDisableKeychainAccess { + "Inactive while \"Disable Keychain access\" is enabled in Advanced." + } else { + "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." + } + + let promptFreeBinding = Binding( + get: { context.settings.claudeOAuthPromptFreeCredentialsEnabled }, + set: { enabled in + guard !context.settings.debugDisableKeychainAccess else { return } + context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled + }) + + return [ + ProviderSettingsToggleDescriptor( + id: "claude-oauth-prompt-free-credentials", + title: "Avoid Keychain prompts (experimental)", + subtitle: subtitle, + binding: promptFreeBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ] + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( @@ -72,6 +104,12 @@ struct ClaudeProviderImplementation: ProviderImplementation { set: { raw in context.settings.claudeCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) + let keychainPromptPolicyBinding = Binding( + get: { context.settings.claudeOAuthKeychainPromptMode.rawValue }, + set: { raw in + context.settings.claudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptMode(rawValue: raw) + ?? .onlyOnUserAction + }) let usageOptions = ClaudeUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) @@ -79,7 +117,17 @@ struct ClaudeProviderImplementation: ProviderImplementation { let cookieOptions = ProviderCookieSourceUI.options( allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) - + let keychainPromptPolicyOptions: [ProviderSettingsPickerOption] = [ + ProviderSettingsPickerOption( + id: ClaudeOAuthKeychainPromptMode.never.rawValue, + title: "Never prompt"), + ProviderSettingsPickerOption( + id: ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue, + title: "Only on user action"), + ProviderSettingsPickerOption( + id: ClaudeOAuthKeychainPromptMode.always.rawValue, + title: "Always allow prompts"), + ] let cookieSubtitle: () -> String? = { ProviderCookieSourceUI.subtitle( source: context.settings.claudeCookieSource, @@ -88,6 +136,13 @@ struct ClaudeProviderImplementation: ProviderImplementation { manual: "Paste a Cookie header from a claude.ai request.", off: "Claude cookies are disabled.") } + let keychainPromptPolicySubtitle: () -> String? = { + if context.settings.debugDisableKeychainAccess { + return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." + } + return "Controls Claude OAuth Keychain prompts when experimental reader mode is off. Choosing " + + "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." + } return [ ProviderSettingsPickerDescriptor( @@ -103,6 +158,16 @@ struct ClaudeProviderImplementation: ProviderImplementation { let label = context.store.sourceLabel(for: .claude) return label == "auto" ? nil : label }), + ProviderSettingsPickerDescriptor( + id: "claude-keychain-prompt-policy", + title: "Keychain prompt policy", + subtitle: "Applies only to the Security.framework OAuth keychain reader.", + dynamicSubtitle: keychainPromptPolicySubtitle, + binding: keychainPromptPolicyBinding, + options: keychainPromptPolicyOptions, + isVisible: { context.settings.claudeOAuthKeychainReadStrategy == .securityFramework }, + isEnabled: { !context.settings.debugDisableKeychainAccess }, + onChange: nil), ProviderSettingsPickerDescriptor( id: "claude-cookie-source", title: "Claude cookies", diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 10ad3ca05..b2bdf0627 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -99,7 +99,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { subtitle: "Choose the MiniMax host (global .io or China mainland .com).", binding: regionBinding, options: regionOptions, - isVisible: { authMode().allowsCookies }, + isVisible: nil, onChange: nil), ] } diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift new file mode 100644 index 000000000..99d8582f1 --- /dev/null +++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift @@ -0,0 +1,95 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct OllamaProviderImplementation: ProviderImplementation { + let id: UsageProvider = .ollama + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.ollamaCookieSource + _ = settings.ollamaCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .ollama(context.settings.ollamaSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.ollamaCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.ollamaCookieSource != .manual { + settings.ollamaCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.ollamaCookieSource.rawValue }, + set: { raw in + context.settings.ollamaCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.ollamaCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from Ollama settings.", + off: "Ollama cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "ollama-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "ollama-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: …", + binding: context.stringBinding(\.ollamaCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "ollama-open-settings", + title: "Open Ollama Settings", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://ollama.com/settings") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.ollamaCookieSource == .manual }, + onActivate: { context.settings.ensureOllamaCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift new file mode 100644 index 000000000..99e0d6504 --- /dev/null +++ b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var ollamaCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .ollama)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .ollama) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .ollama, field: "cookieHeader", value: newValue) + } + } + + var ollamaCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .ollama, fallback: .auto) } + set { + self.updateProviderConfig(provider: .ollama) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .ollama, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureOllamaCookieLoaded() {} +} + +extension SettingsStore { + func ollamaSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .OllamaProviderSettings { + ProviderSettingsSnapshot.OllamaProviderSettings( + cookieSource: self.ollamaSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.ollamaSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func ollamaSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.ollamaCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .ollama), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .ollama, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func ollamaSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.ollamaCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .ollama), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .ollama).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift new file mode 100644 index 000000000..d584a2430 --- /dev/null +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -0,0 +1,57 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct OpenRouterProviderImplementation: ProviderImplementation { + let id: UsageProvider = .openrouter + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.openRouterAPIToken + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if OpenRouterSettingsReader.apiToken(environment: context.environment) != nil { + return true + } + return !context.settings.openRouterAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "openrouter-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. " + + "Get your key from openrouter.ai/settings/keys and set a key spending limit " + + "there to enable API key quota tracking.", + kind: .secure, + placeholder: "sk-or-v1-...", + binding: context.stringBinding(\.openRouterAPIToken), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift new file mode 100644 index 000000000..130cdf3dd --- /dev/null +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var openRouterAPIToken: String { + get { self.configSnapshot.providerConfig(for: .openrouter)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .openrouter) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .openrouter, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3b..1cb530ce8 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -10,6 +10,7 @@ enum ProviderImplementationRegistry { private static let lock = NSLock() private static let store = Store() + // swiftlint:disable:next cyclomatic_complexity private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) { switch provider { case .codex: CodexProviderImplementation() @@ -29,7 +30,10 @@ enum ProviderImplementationRegistry { case .jetbrains: JetBrainsProviderImplementation() case .kimik2: KimiK2ProviderImplementation() case .amp: AmpProviderImplementation() + case .ollama: OllamaProviderImplementation() case .synthetic: SyntheticProviderImplementation() + case .openrouter: OpenRouterProviderImplementation() + case .warp: WarpProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index c624a671e..d5a85b8f7 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -112,6 +112,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { let binding: Binding let options: [ProviderSettingsPickerOption] let isVisible: (() -> Bool)? + let isEnabled: (() -> Bool)? let onChange: ((_ selection: String) async -> Void)? let trailingText: (() -> String?)? @@ -123,6 +124,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { binding: Binding, options: [ProviderSettingsPickerOption], isVisible: (() -> Bool)?, + isEnabled: (() -> Bool)? = nil, onChange: ((_ selection: String) async -> Void)?, trailingText: (() -> String?)? = nil) { @@ -133,6 +135,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { self.binding = binding self.options = options self.isVisible = isVisible + self.isEnabled = isEnabled self.onChange = onChange self.trailingText = trailingText } diff --git a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift new file mode 100644 index 000000000..e9cb82de9 --- /dev/null +++ b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct WarpProviderImplementation: ProviderImplementation { + let id: UsageProvider = .warp + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.warpAPIToken + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "warp-api-token", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, " + + "then create one.", + kind: .secure, + placeholder: "wk-...", + binding: context.stringBinding(\.warpAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "warp-open-api-keys", + title: "Open Warp API Key Guide", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://docs.warp.dev/reference/cli/api-keys") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift b/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift new file mode 100644 index 000000000..735b700b8 --- /dev/null +++ b/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var warpAPIToken: String { + get { self.configSnapshot.providerConfig(for: .warp)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .warp) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .warp, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-ollama.svg b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg new file mode 100644 index 000000000..23b80bc53 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg new file mode 100644 index 000000000..94e78feee --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-warp.svg b/Sources/CodexBar/Resources/ProviderIcon-warp.svg new file mode 100644 index 000000000..30a992a08 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-warp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 010f9b6da..1726a9345 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -239,6 +239,37 @@ extension SettingsStore { } } + var claudeOAuthKeychainPromptMode: ClaudeOAuthKeychainPromptMode { + get { + let raw = self.defaultsState.claudeOAuthKeychainPromptModeRaw + return ClaudeOAuthKeychainPromptMode(rawValue: raw ?? "") ?? .onlyOnUserAction + } + set { + self.defaultsState.claudeOAuthKeychainPromptModeRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainPromptMode") + } + } + + var claudeOAuthKeychainReadStrategy: ClaudeOAuthKeychainReadStrategy { + get { + let raw = self.defaultsState.claudeOAuthKeychainReadStrategyRaw + return ClaudeOAuthKeychainReadStrategy(rawValue: raw ?? "") ?? .securityFramework + } + set { + self.defaultsState.claudeOAuthKeychainReadStrategyRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainReadStrategy") + } + } + + var claudeOAuthPromptFreeCredentialsEnabled: Bool { + get { self.claudeOAuthKeychainReadStrategy == .securityCLIExperimental } + set { + self.claudeOAuthKeychainReadStrategy = newValue + ? .securityCLIExperimental + : .securityFramework + } + } + var claudeWebExtrasEnabled: Bool { get { self.claudeWebExtrasEnabledRaw } set { self.claudeWebExtrasEnabledRaw = newValue } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 686f28d78..0d5f43d0c 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -23,6 +23,8 @@ extension SettingsStore { _ = self.costUsageEnabled _ = self.hidePersonalInfo _ = self.randomBlinkEnabled + _ = self.claudeOAuthKeychainPromptMode + _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled @@ -39,6 +41,7 @@ extension SettingsStore { _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.colorCodedIcons + _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons _ = self.zaiAPIToken @@ -55,7 +58,9 @@ extension SettingsStore { _ = self.kimiK2APIToken _ = self.augmentCookieHeader _ = self.ampCookieHeader + _ = self.ollamaCookieHeader _ = self.copilotAPIToken + _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index c3136c272..210a286d4 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -4,6 +4,16 @@ import Foundation extension SettingsStore { func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference { if provider == .zai { return .primary } + if provider == .openrouter { + let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" + let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic + switch preference { + case .automatic, .primary: + return preference + case .secondary, .average: + return .automatic + } + } let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic if preference == .average, !self.menuBarMetricSupportsAverage(for: provider) { @@ -17,6 +27,15 @@ extension SettingsStore { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.primary.rawValue return } + if provider == .openrouter { + switch preference { + case .automatic, .primary: + self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue + case .secondary, .average: + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + } + return + } self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 462a95d5d..e5d9f8f2c 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -209,6 +209,8 @@ extension SettingsStore { let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false + let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") + let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true @@ -247,6 +249,8 @@ extension SettingsStore { hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, menuBarShowsHighestUsage: menuBarShowsHighestUsage, + claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, + claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a8b7106e8..e81451a73 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -24,6 +24,8 @@ struct SettingsDefaultsState: Sendable { var hidePersonalInfo: Bool var randomBlinkEnabled: Bool var menuBarShowsHighestUsage: Bool + var claudeOAuthKeychainPromptModeRaw: String? + var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 65485d870..efe63ffd8 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -230,7 +230,7 @@ extension StatusItemController { case .missingBinary: self.presentLoginAlert( title: "Claude CLI not found", - message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-cli) and try again.") + message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.") case let .launchFailed(message): self.presentLoginAlert(title: "Could not start claude /login", message: message) case .timedOut: diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 6f1d0fd0e..8d03fe4df 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -195,6 +195,23 @@ extension StatusItemController { // user setting we pass either "percent left" or "percent used". var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + if showUsed, + primaryProvider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining <= 0 + { + // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. + weekly = 0 + } + if showUsed, + primaryProvider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining > 0, + weekly == 0 + { + // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. + weekly = Self.loadingPercentEpsilon + } var credits: Double? = primaryProvider == .codex ? self.store.credits?.remaining : nil var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? @@ -248,6 +265,16 @@ extension StatusItemController { return } + if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), + let brand = ProviderBrandIcon.image(for: primaryProvider) + { + self.setButtonTitle(nil, for: button) + self.setButtonImage( + Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), + for: button) + return + } + self.setButtonTitle(nil, for: button) if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) @@ -292,8 +319,37 @@ extension StatusItemController { self.setButtonTintColor(usageColor, for: button) return } + + if Self.shouldUseOpenRouterBrandFallback(provider: provider, snapshot: snapshot), + let brand = ProviderBrandIcon.image(for: provider) + { + self.setButtonTitle(nil, for: button) + self.setButtonImage( + Self.brandImageWithStatusOverlay( + brand: brand, + statusIndicator: self.store.statusIndicator(for: provider)), + for: button) + return + } var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + if showUsed, + provider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining <= 0 + { + // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. + weekly = 0 + } + if showUsed, + provider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining > 0, + weekly == 0 + { + // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. + weekly = Self.loadingPercentEpsilon + } var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -319,7 +375,14 @@ extension StatusItemController { } let style: IconStyle = self.store.style(for: provider) - let blink = self.blinkAmount(for: provider) + let isLoading = phase != nil && self.shouldAnimate(provider: provider) + let blink: CGFloat = { + guard isLoading, style == .warp, let phase else { + return self.blinkAmount(for: provider) + } + let normalized = (sin(phase * 3) + 1) / 2 + return CGFloat(max(0, min(normalized, 1))) + }() let wiggle = self.wiggleAmount(for: provider) let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° if let morphProgress { @@ -377,7 +440,7 @@ extension StatusItemController { case .weekly: snapshot?.secondary ?? snapshot?.primary } - return MenuBarDisplayText.displayText( + let displayText = MenuBarDisplayText.displayText( mode: self.settings.menuBarDisplayMode, provider: provider, percentWindow: percentWindow, @@ -385,6 +448,23 @@ extension StatusItemController { showUsed: self.settings.usageBarsShowUsed, separatorStyle: self.settings.menuBarSeparatorStyle, paceTimeWindow: self.settings.menuBarPaceTimeWindow) + + let sessionExhausted = (snapshot?.primary?.remainingPercent ?? 100) <= 0 + let weeklyExhausted = (snapshot?.secondary?.remainingPercent ?? 100) <= 0 + + if provider == .codex, + self.settings.menuBarDisplayMode == .percent, + !self.settings.usageBarsShowUsed, + sessionExhausted || weeklyExhausted, + let creditsRemaining = self.store.credits?.remaining, + creditsRemaining > 0 + { + return UsageFormatter + .creditsString(from: creditsRemaining) + .replacingOccurrences(of: " left", with: "") + } + + return displayText } private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { @@ -457,6 +537,9 @@ extension StatusItemController { let isStale = self.store.isStale(provider: provider) let hasData = self.store.snapshot(for: provider) != nil + if provider == .warp, !hasData, self.store.refreshingProviders.contains(provider) { + return true + } return !hasData && !isStale } @@ -500,6 +583,58 @@ extension StatusItemController { } } + nonisolated static func shouldUseOpenRouterBrandFallback( + provider: UsageProvider, + snapshot: UsageSnapshot?) -> Bool + { + guard provider == .openrouter, + let openRouterUsage = snapshot?.openRouterUsage + else { + return false + } + return openRouterUsage.keyQuotaStatus == .noLimitConfigured + } + + nonisolated static func brandImageWithStatusOverlay( + brand: NSImage, + statusIndicator: ProviderStatusIndicator) -> NSImage + { + guard statusIndicator.hasIssue else { return brand } + + let image = NSImage(size: brand.size) + image.lockFocus() + brand.draw( + at: .zero, + from: NSRect(origin: .zero, size: brand.size), + operation: .sourceOver, + fraction: 1.0) + Self.drawBrandStatusOverlay(indicator: statusIndicator, size: brand.size) + image.unlockFocus() + image.isTemplate = brand.isTemplate + return image + } + + private nonisolated static func drawBrandStatusOverlay(indicator: ProviderStatusIndicator, size: NSSize) { + guard indicator.hasIssue else { return } + + let color = NSColor.labelColor + switch indicator { + case .minor, .maintenance: + let dotSize = CGSize(width: 4, height: 4) + let dotOrigin = CGPoint(x: size.width - dotSize.width - 2, y: 2) + color.setFill() + NSBezierPath(ovalIn: CGRect(origin: dotOrigin, size: dotSize)).fill() + case .major, .critical, .unknown: + color.setFill() + let lineRect = CGRect(x: size.width - 6, y: 4, width: 2, height: 6) + NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1).fill() + let dotRect = CGRect(x: size.width - 6, y: 2, width: 2, height: 2) + NSBezierPath(ovalIn: dotRect).fill() + case .none: + break + } + } + private func advanceAnimationPattern() { let patterns = LoadingPattern.allCases if let idx = patterns.firstIndex(of: self.animationPattern) { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index ffaf907a3..e8aef56aa 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -52,9 +52,9 @@ extension StatusItemController { var provider: UsageProvider? if self.shouldMergeIcons { - self.selectedMenuProvider = self.resolvedMenuProvider() - self.lastMenuProvider = self.selectedMenuProvider ?? .codex - provider = self.selectedMenuProvider + let resolvedProvider = self.resolvedMenuProvider() + self.lastMenuProvider = resolvedProvider ?? .codex + provider = resolvedProvider } else { if let menuProvider = self.menuProviders[ObjectIdentifier(menu)] { self.lastMenuProvider = menuProvider @@ -120,9 +120,11 @@ extension StatusItemController { let hasTokenAccountSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders + let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && switcherProvidersMatch && + switcherUsageBarsShowUsedMatch && tokenAccountDisplay == nil && !hasTokenAccountSwitcher && !menu.items.isEmpty && @@ -154,6 +156,7 @@ extension StatusItemController { // Track which providers the switcher was built with for smart update detection if self.shouldMergeIcons, enabledProviders.count > 1 { self.lastSwitcherProviders = enabledProviders + self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed } self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( @@ -542,7 +545,7 @@ extension StatusItemController { private func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { - return self.selectedMenuProvider ?? self.resolvedMenuProvider() + return self.resolvedMenuProvider() } if let provider = self.menuProviders[ObjectIdentifier(menu)] { return provider @@ -754,7 +757,24 @@ extension StatusItemController { let snapshot = self.store.snapshot(for: provider) let showUsed = self.settings.usageBarsShowUsed let primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent - let weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent + if showUsed, + provider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining <= 0 + { + // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. + weekly = 0 + } + if showUsed, + provider == .warp, + let remaining = snapshot?.secondary?.remainingPercent, + remaining > 0, + weekly == 0 + { + // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. + weekly = 0.0001 + } let credits = provider == .codex ? self.store.credits?.remaining : nil let stale = self.store.isStale(provider: provider) let style = self.store.style(for: provider) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dce63bf76..92399ede5 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -81,6 +81,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastProviderOrder: [UsageProvider] private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool + private var lastObservedUsageBarsShowUsed: Bool + /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. + /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. + var lastSwitcherUsageBarsShowUsed: Bool /// Tracks which providers the merged menu's switcher was built with, to detect when it needs full rebuild. var lastSwitcherProviders: [UsageProvider] = [] let loginLogger = CodexBarLog.logger(LogCategories.login) @@ -125,7 +129,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) case .automatic: - if provider == .factory { + if provider == .factory || provider == .kimi { return snapshot?.secondary ?? snapshot?.primary } return snapshot?.primary ?? snapshot?.secondary @@ -152,6 +156,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastProviderOrder = settings.providerOrder self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons + self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed + self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.statusBar = statusBar let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). @@ -291,6 +297,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastSwitcherShowsIcons = showsIcons shouldRefresh = true } + let usageBarsShowUsed = self.settings.usageBarsShowUsed + if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed { + self.lastObservedUsageBarsShowUsed = usageBarsShowUsed + shouldRefresh = true + } return shouldRefresh } diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index 4a72e3520..4522a0ffb 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -14,6 +14,7 @@ extension UsageStore { "kimiCookieSource": self.settings.kimiCookieSource.rawValue, "augmentCookieSource": self.settings.augmentCookieSource.rawValue, "ampCookieSource": self.settings.ampCookieSource.rawValue, + "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, "openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0", "claudeWebExtras": self.settings.claudeWebExtrasEnabled ? "1" : "0", ] diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0963b8a63..0c456cb75 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -42,7 +42,18 @@ extension UsageStore { } } - let outcome = await spec.fetch() + let fetchContext = spec.makeFetchContext() + let descriptor = spec.descriptor + // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. + let outcome = await withTaskGroup( + of: ProviderFetchOutcome.self, + returning: ProviderFetchOutcome.self) + { group in + group.addTask { + await descriptor.fetchOutcome(context: fetchContext) + } + return await group.next()! + } if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7127a1233..3e55ffa9f 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -192,14 +192,6 @@ extension UsageStore { accountEmail: resolvedEmail, accountOrganization: existing?.accountOrganization, loginMethod: existing?.loginMethod) - return UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary, - providerCost: snapshot.providerCost, - zaiUsage: snapshot.zaiUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: identity) + return snapshot.withIdentity(identity) } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1a555b8b0..6d017f17d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -193,6 +193,7 @@ final class UsageStore { @ObservationIgnored private var pathDebugRefreshTask: Task? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored var weeklyHistoryLastWrite: [String: Date] = [:] @@ -315,8 +316,8 @@ final class UsageStore { for provider in self.enabledProviders() { guard let snapshot = self.snapshots[provider] else { continue } // Use the same window selection logic as menuBarPercentWindow: - // Factory uses secondary (premium) first, others use primary (session) first. - let window: RateWindow? = if provider == .factory { + // Factory and Kimi use secondary first, others use primary first. + let window: RateWindow? = if provider == .factory || provider == .kimi { snapshot.secondary ?? snapshot.primary } else { snapshot.primary ?? snapshot.secondary @@ -397,10 +398,18 @@ final class UsageStore { } func isProviderAvailable(_ provider: UsageProvider) -> Bool { + // Availability should mirror the effective fetch environment, including token-account overrides. + // Otherwise providers (notably token-account-backed API providers) can fetch successfully but be + // hidden from the menu because their credentials are not in ProcessInfo's environment. + let environment = ProviderRegistry.makeEnvironment( + base: ProcessInfo.processInfo.environment, + provider: provider, + settings: self.settings, + tokenOverride: nil) let context = ProviderAvailabilityContext( provider: provider, settings: self.settings, - environment: ProcessInfo.processInfo.environment) + environment: environment) return ProviderCatalog.implementation(for: provider)? .isAvailable(context: context) ?? true @@ -426,31 +435,38 @@ final class UsageStore { func refresh(forceTokenUsage: Bool = false) async { guard !self.isRefreshing else { return } - self.isRefreshing = true - defer { self.isRefreshing = false } + let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup - await withTaskGroup(of: Void.self) { group in - for provider in UsageProvider.allCases { - group.addTask { await self.refreshProvider(provider) } - group.addTask { await self.refreshStatus(provider) } + await ProviderRefreshContext.$current.withValue(refreshPhase) { + self.isRefreshing = true + defer { + self.isRefreshing = false + self.hasCompletedInitialRefresh = true + } + + await withTaskGroup(of: Void.self) { group in + for provider in UsageProvider.allCases { + group.addTask { await self.refreshProvider(provider) } + group.addTask { await self.refreshStatus(provider) } + } + group.addTask { await self.refreshCreditsIfNeeded() } } - group.addTask { await self.refreshCreditsIfNeeded() } - } - // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. - self.scheduleTokenRefresh(force: forceTokenUsage) + // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. + self.scheduleTokenRefresh(force: forceTokenUsage) - // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch). - // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. - await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) + // OpenAI web scrape depends on the current Codex account email (which can change after login/account + // switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. + await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) - if self.openAIDashboardRequiresLogin { - await self.refreshProvider(.codex) - await self.refreshCreditsIfNeeded() - } + if self.openAIDashboardRequiresLogin { + await self.refreshProvider(.codex) + await self.refreshCreditsIfNeeded() + } - self.persistWidgetSnapshot(reason: "refresh") - self.recordWeeklyHistoryIfNeeded() + self.persistWidgetSnapshot(reason: "refresh") + self.recordWeeklyHistoryIfNeeded() + } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -1137,102 +1153,99 @@ extension UsageStore { let keepCLISessionsAlive = self.settings.debugKeepCLISessionsAlive let cursorCookieSource = self.settings.cursorCookieSource let cursorCookieHeader = self.settings.cursorCookieHeader + let ampCookieSource = self.settings.ampCookieSource + let ampCookieHeader = self.settings.ampCookieHeader + let ollamaCookieSource = self.settings.ollamaCookieSource + let ollamaCookieHeader = self.settings.ollamaCookieHeader + let processEnvironment = ProcessInfo.processInfo.environment + let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey + let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true) + let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil + let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openrouter, + config: self.settings.providerConfig(for: .openrouter)) return await Task.detached(priority: .utility) { () -> String in + let unimplementedDebugLogMessages: [UsageProvider: String] = [ + .gemini: "Gemini debug log not yet implemented", + .antigravity: "Antigravity debug log not yet implemented", + .opencode: "OpenCode debug log not yet implemented", + .factory: "Droid debug log not yet implemented", + .copilot: "Copilot debug log not yet implemented", + .vertexai: "Vertex AI debug log not yet implemented", + .kiro: "Kiro debug log not yet implemented", + .kimi: "Kimi debug log not yet implemented", + .kimik2: "Kimi K2 debug log not yet implemented", + .jetbrains: "JetBrains AI debug log not yet implemented", + ] + let text: String switch provider { case .codex: - let raw = await self.codexFetcher.debugRawRateLimits() - await MainActor.run { self.probeLogs[.codex] = raw } - return raw + text = await self.codexFetcher.debugRawRateLimits() case .claude: - let text = await self.debugClaudeLog( + text = await self.debugClaudeLog( claudeWebExtrasEnabled: claudeWebExtrasEnabled, claudeUsageDataSource: claudeUsageDataSource, claudeCookieSource: claudeCookieSource, claudeCookieHeader: claudeCookieHeader, keepCLISessionsAlive: keepCLISessionsAlive) - await MainActor.run { self.probeLogs[.claude] = text } - return text case .zai: let resolution = ProviderTokenResolver.zaiResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" - let text = "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - await MainActor.run { self.probeLogs[.zai] = text } - return text + text = "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .synthetic: let resolution = ProviderTokenResolver.syntheticResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" - let text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - await MainActor.run { self.probeLogs[.synthetic] = text } - return text - case .gemini: - let text = "Gemini debug log not yet implemented" - await MainActor.run { self.probeLogs[.gemini] = text } - return text - case .antigravity: - let text = "Antigravity debug log not yet implemented" - await MainActor.run { self.probeLogs[.antigravity] = text } - return text + text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .cursor: - let text = await self.debugCursorLog( + text = await self.debugCursorLog( cursorCookieSource: cursorCookieSource, cursorCookieHeader: cursorCookieHeader) - await MainActor.run { self.probeLogs[.cursor] = text } - return text - case .opencode: - let text = "OpenCode debug log not yet implemented" - await MainActor.run { self.probeLogs[.opencode] = text } - return text - case .factory: - let text = "Droid debug log not yet implemented" - await MainActor.run { self.probeLogs[.factory] = text } - return text - case .copilot: - let text = "Copilot debug log not yet implemented" - await MainActor.run { self.probeLogs[.copilot] = text } - return text case .minimax: let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() let tokenSource = tokenResolution?.source.rawValue ?? "none" let cookieSource = cookieResolution?.source.rawValue ?? "none" - let text = "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + + text = "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + "source=\(cookieSource)" - await MainActor.run { self.probeLogs[.minimax] = text } - return text - case .vertexai: - let text = "Vertex AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.vertexai] = text } - return text - case .kiro: - let text = "Kiro debug log not yet implemented" - await MainActor.run { self.probeLogs[.kiro] = text } - return text case .augment: - let text = await self.debugAugmentLog() - await MainActor.run { self.probeLogs[.augment] = text } - return text - case .kimi: - let text = "Kimi debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimi] = text } - return text - case .kimik2: - let text = "Kimi K2 debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimik2] = text } - return text + text = await self.debugAugmentLog() case .amp: - let text = await self.debugAmpLog( - ampCookieSource: self.settings.ampCookieSource, - ampCookieHeader: self.settings.ampCookieHeader) - await MainActor.run { self.probeLogs[.amp] = text } - return text - case .jetbrains: - let text = "JetBrains AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.jetbrains] = text } - return text + text = await self.debugAmpLog( + ampCookieSource: ampCookieSource, + ampCookieHeader: ampCookieHeader) + case .ollama: + text = await self.debugOllamaLog( + ollamaCookieSource: ollamaCookieSource, + ollamaCookieHeader: ollamaCookieHeader) + case .openrouter: + let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) + let hasAny = resolution != nil + let source: String = if resolution == nil { + "none" + } else if openRouterHasConfigToken, openRouterHasEnvToken { + "settings-config (overrides env)" + } else if openRouterHasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + text = "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .warp: + let resolution = ProviderTokenResolver.warpResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2, .jetbrains: + text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } + + await MainActor.run { self.probeLogs[provider] = text } + return text }.value } @@ -1243,7 +1256,14 @@ extension UsageStore { claudeCookieHeader: String, keepCLISessionsAlive: Bool) async -> String { - await self.runWithTimeout(seconds: 15) { + struct OAuthDebugProbe: Sendable { + let hasCredentials: Bool + let ownerRawValue: String + let sourceRawValue: String + let isExpired: Bool + } + + return await self.runWithTimeout(seconds: 15) { var lines: [String] = [] let manualHeader = claudeCookieSource == .manual ? CookieHeaderNormalizer.normalize(claudeCookieHeader) @@ -1253,12 +1273,20 @@ extension UsageStore { } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) { msg in lines.append(msg) } } - // Don't prompt for keychain access during debug dump - let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord( - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true, - allowClaudeKeychainRepairWithoutPrompt: false) - let hasOAuthCredentials = oauthRecord?.credentials.scopes.contains("user:profile") == true + // Run potentially blocking keychain probes off MainActor so debug dumps don't stall UI rendering. + let oauthProbe = await Task.detached(priority: .utility) { + // Don't prompt for keychain access during debug dump. + let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord( + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + return OAuthDebugProbe( + hasCredentials: oauthRecord?.credentials.scopes.contains("user:profile") == true, + ownerRawValue: oauthRecord?.owner.rawValue ?? "none", + sourceRawValue: oauthRecord?.source.rawValue ?? "none", + isExpired: oauthRecord?.credentials.isExpired ?? false) + }.value + let hasOAuthCredentials = oauthProbe.hasCredentials let hasClaudeBinary = ClaudeOAuthDelegatedRefreshCoordinator.isClaudeCLIAvailable() let delegatedCooldownSeconds = ClaudeOAuthDelegatedRefreshCoordinator.cooldownRemainingSeconds() @@ -1266,19 +1294,20 @@ extension UsageStore { selectedDataSource: claudeUsageDataSource, webExtrasEnabled: claudeWebExtrasEnabled, hasWebSession: hasKey, + hasCLI: hasClaudeBinary, hasOAuthCredentials: hasOAuthCredentials) if claudeUsageDataSource == .auto { - lines.append("pipeline_order=oauth→web→cli") + lines.append("pipeline_order=oauth→cli→web") lines.append("auto_heuristic=\(strategy.dataSource.rawValue)") } else { lines.append("strategy=\(strategy.dataSource.rawValue)") } lines.append("hasSessionKey=\(hasKey)") lines.append("hasOAuthCredentials=\(hasOAuthCredentials)") - lines.append("oauthCredentialOwner=\(oauthRecord?.owner.rawValue ?? "none")") - lines.append("oauthCredentialSource=\(oauthRecord?.source.rawValue ?? "none")") - lines.append("oauthCredentialExpired=\(oauthRecord?.credentials.isExpired ?? false)") + lines.append("oauthCredentialOwner=\(oauthProbe.ownerRawValue)") + lines.append("oauthCredentialSource=\(oauthProbe.sourceRawValue)") + lines.append("oauthCredentialExpired=\(oauthProbe.isExpired)") lines.append("delegatedRefreshCLIAvailable=\(hasClaudeBinary)") lines.append("delegatedRefreshCooldownActive=\(delegatedCooldownSeconds != nil)") if let delegatedCooldownSeconds { @@ -1413,6 +1442,21 @@ extension UsageStore { } } + private func debugOllamaLog( + ollamaCookieSource: ProviderCookieSource, + ollamaCookieHeader: String) async -> String + { + await self.runWithTimeout(seconds: 15) { + let fetcher = OllamaUsageFetcher(browserDetection: self.browserDetection) + let manualHeader = ollamaCookieSource == .manual + ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) + : nil + return await fetcher.debugRawProbe( + cookieHeaderOverride: manualHeader, + manualCookieMode: ollamaCookieSource == .manual) + } + } + private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String { await withTaskGroup(of: String?.self) { group -> String in group.addTask { await operation() } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 914061fa5..649ba00cc 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -21,7 +21,14 @@ enum CLIRenderer { if let primary = snapshot.primary { lines.append(self.rateLine(title: meta.sessionLabel, window: primary, useColor: context.useColor)) - if let reset = self.resetLine(for: primary, style: context.resetStyle, now: now) { + if provider == .warp { + if let reset = self.resetLineForWarp(window: primary, style: context.resetStyle, now: now) { + lines.append(self.subtleLine(reset, useColor: context.useColor)) + } + if let detail = self.detailLineForWarp(window: primary) { + lines.append(self.subtleLine(detail, useColor: context.useColor)) + } + } else if let reset = self.resetLine(for: primary, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } } else if let cost = snapshot.providerCost { @@ -36,7 +43,14 @@ enum CLIRenderer { if let pace = self.paceLine(provider: provider, window: weekly, useColor: context.useColor, now: now) { lines.append(pace) } - if let reset = self.resetLine(for: weekly, style: context.resetStyle, now: now) { + if provider == .warp { + if let reset = self.resetLineForWarp(window: weekly, style: context.resetStyle, now: now) { + lines.append(self.subtleLine(reset, useColor: context.useColor)) + } + if let detail = self.detailLineForWarp(window: weekly) { + lines.append(self.subtleLine(detail, useColor: context.useColor)) + } + } else if let reset = self.resetLine(for: weekly, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } } @@ -84,6 +98,23 @@ enum CLIRenderer { UsageFormatter.resetLine(for: window, style: style, now: now) } + private static func resetLineForWarp(window: RateWindow, style: ResetTimeDisplayStyle, now: Date) -> String? { + // Warp uses resetDescription for non-reset detail. Only render "Resets ..." when a concrete reset date exists. + guard window.resetsAt != nil else { return nil } + let resetOnlyWindow = RateWindow( + usedPercent: window.usedPercent, + windowMinutes: window.windowMinutes, + resetsAt: window.resetsAt, + resetDescription: nil) + return UsageFormatter.resetLine(for: resetOnlyWindow, style: style, now: now) + } + + private static func detailLineForWarp(window: RateWindow) -> String? { + guard let desc = window.resetDescription else { return nil } + let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private static func headerLine(_ header: String, useColor: Bool) -> String { let decorated = "== \(header) ==" guard useColor else { return decorated } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4809cfb06..c1617905e 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -135,6 +135,11 @@ struct TokenAccountCLIContext { amp: ProviderSettingsSnapshot.AmpProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .ollama: + return self.makeSnapshot( + ollama: ProviderSettingsSnapshot.OllamaProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .kimi: return self.makeSnapshot( kimi: ProviderSettingsSnapshot.KimiProviderSettings( @@ -147,7 +152,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } } @@ -163,6 +168,7 @@ struct TokenAccountCLIContext { kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil, augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, + ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( @@ -176,6 +182,7 @@ struct TokenAccountCLIContext { kimi: kimi, augment: augment, amp: amp, + ollama: ollama, jetbrains: jetbrains) } @@ -215,15 +222,7 @@ struct TokenAccountCLIContext { accountEmail: resolvedEmail, accountOrganization: existing?.accountOrganization, loginMethod: existing?.loginMethod) - return UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary, - providerCost: snapshot.providerCost, - zaiUsage: snapshot.zaiUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: identity) + return snapshot.withIdentity(identity) } func effectiveSourceMode( @@ -273,13 +272,13 @@ struct TokenAccountCLIContext { account: ProviderTokenAccount?, config: ProviderConfig?) -> ProviderCookieSource { - if let override = config?.cookieSource { return override } if let account, TokenAccountSupportCatalog.support(for: provider)?.requiresManualCookieSource == true { if provider == .claude, TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) { return .off } return .manual } + if let override = config?.cookieSource { return override } if config?.sanitizedCookieHeader != nil { return .manual } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index a05b2dd25..9a72d340a 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -21,6 +21,12 @@ public enum ProviderConfigEnvironment { } case .synthetic: env[SyntheticSettingsReader.apiKeyKey] = apiKey + case .warp: + if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first { + env[key] = apiKey + } + case .openrouter: + env[OpenRouterSettingsReader.envKey] = apiKey default: break } diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index c05cd5bec..0ca26604e 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -34,7 +34,44 @@ public struct KeychainPromptContext: Sendable { } public enum KeychainPromptHandler { + final class HandlerStore: @unchecked Sendable { + let handler: (KeychainPromptContext) -> Void + + init(handler: @escaping (KeychainPromptContext) -> Void) { + self.handler = handler + } + } + + @TaskLocal private static var taskHandlerStore: HandlerStore? public nonisolated(unsafe) static var handler: ((KeychainPromptContext) -> Void)? + + public static func notify(_ context: KeychainPromptContext) { + if let taskHandlerStore { + taskHandlerStore.handler(context) + return + } + self.handler?(context) + } + + #if DEBUG + static func withHandlerForTesting( + _ handler: ((KeychainPromptContext) -> Void)?, + operation: () throws -> T) rethrows -> T + { + try self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) { + try operation() + } + } + + static func withHandlerForTesting( + _ handler: ((KeychainPromptContext) -> Void)?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) { + try await operation() + } + } + #endif } public enum KeychainAccessPreflight { @@ -48,16 +85,50 @@ public enum KeychainAccessPreflight { private static let log = CodexBarLog.logger(LogCategories.keychainPreflight) #if DEBUG + final class CheckGenericPasswordOverrideStore: @unchecked Sendable { + let check: (String, String?) -> Outcome + + init(check: @escaping (String, String?) -> Outcome) { + self.check = check + } + } + + @TaskLocal private static var taskCheckGenericPasswordOverrideStore: CheckGenericPasswordOverrideStore? private nonisolated(unsafe) static var checkGenericPasswordOverride: ((String, String?) -> Outcome)? static func setCheckGenericPasswordOverrideForTesting(_ override: ((String, String?) -> Outcome)?) { self.checkGenericPasswordOverride = override } + + static func withCheckGenericPasswordOverrideForTesting( + _ override: ((String, String?) -> Outcome)?, + operation: () throws -> T) rethrows -> T + { + try self.$taskCheckGenericPasswordOverrideStore.withValue( + override.map(CheckGenericPasswordOverrideStore.init(check:))) + { + try operation() + } + } + + static func withCheckGenericPasswordOverrideForTesting( + _ override: ((String, String?) -> Outcome)?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskCheckGenericPasswordOverrideStore.withValue( + override.map(CheckGenericPasswordOverrideStore.init(check:))) + { + try await operation() + } + } #endif public static func checkGenericPassword(service: String, account: String?) -> Outcome { #if os(macOS) #if DEBUG + if let override = self.taskCheckGenericPasswordOverrideStore { + return override.check(service, account) + } if let override = self.checkGenericPasswordOverride { return override(service, account) } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index d3d31c8fe..37a7726ef 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -40,7 +40,9 @@ public enum LogCategories { public static let notifications = "notifications" public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" + public static let ollama = "ollama" public static let opencodeUsage = "opencode-usage" + public static let openRouterUsage = "openrouter-usage" public static let providerDetection = "provider-detection" public static let providers = "providers" public static let sessionQuota = "sessionQuota" @@ -54,6 +56,7 @@ public enum LogCategories { public static let tokenCost = "token-cost" public static let ttyRunner = "tty-runner" public static let vertexAIFetcher = "vertexai-fetcher" + public static let warpUsage = "warp-usage" public static let webkitTeardown = "webkit-teardown" public static let zaiSettings = "zai-settings" public static let zaiTokenStore = "zai-token-store" diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 0bf4e24b8..f2f8c7e3a 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -19,13 +19,20 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if Self.shouldIgnoreNavigationError(error) { return } self.completeOnce(.failure(error)) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + if Self.shouldIgnoreNavigationError(error) { return } self.completeOnce(.failure(error)) } + nonisolated static func shouldIgnoreNavigationError(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled + } + private func completeOnce(_ result: Result) { guard !self.hasCompleted else { return } self.hasCompleted = true diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index 02b52ccad..3ef31c4cb 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -261,6 +261,9 @@ public struct AmpUsageFetcher: Sendable { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { throw AmpUsageError.invalidCredentials } + if diagnostics.detectedLoginRedirect { + throw AmpUsageError.invalidCredentials + } throw AmpUsageError.networkError("HTTP \(httpResponse.statusCode)") } @@ -277,6 +280,7 @@ public struct AmpUsageFetcher: Sendable { private let cookieHeader: String private let logger: ((String) -> Void)? var redirects: [String] = [] + private(set) var detectedLoginRedirect = false init(cookieHeader: String, logger: ((String) -> Void)?) { self.cookieHeader = cookieHeader @@ -293,6 +297,16 @@ public struct AmpUsageFetcher: Sendable { let from = response.url?.absoluteString ?? "unknown" let to = request.url?.absoluteString ?? "unknown" self.redirects.append("\(response.statusCode) \(from) -> \(to)") + + if let toURL = request.url, AmpUsageFetcher.isLoginRedirect(toURL) { + if let logger { + logger("[amp] Detected login redirect, aborting (invalid session)") + } + self.detectedLoginRedirect = true + completionHandler(nil) + return + } + var updated = request if AmpUsageFetcher.shouldAttachCookie(to: request.url), !self.cookieHeader.isEmpty { updated.setValue(self.cookieHeader, forHTTPHeaderField: "Cookie") @@ -364,4 +378,25 @@ public struct AmpUsageFetcher: Sendable { if host == "ampcode.com" || host == "www.ampcode.com" { return true } return host.hasSuffix(".ampcode.com") } + + static func isLoginRedirect(_ url: URL) -> Bool { + guard self.shouldAttachCookie(to: url) else { return false } + + let path = url.path.lowercased() + let components = path.split(separator: "/").map(String.init) + if components.contains("login") { return true } + if components.contains("signin") { return true } + if components.contains("sign-in") { return true } + + // Amp currently redirects to /auth/sign-in?returnTo=... when session is invalid. Keep this slightly broader + // than one exact path so we keep working if Amp changes auth routes. + if components.contains("auth") { + let query = url.query?.lowercased() ?? "" + if query.contains("returnto=") { return true } + if query.contains("redirect=") { return true } + if query.contains("redirectto=") { return true } + } + + return false + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift new file mode 100644 index 000000000..a62a13819 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift @@ -0,0 +1,183 @@ +import Foundation + +#if os(macOS) +import Security +#endif + +public struct ClaudeOAuthCredentials: Sendable { + public let accessToken: String + public let refreshToken: String? + public let expiresAt: Date? + public let scopes: [String] + public let rateLimitTier: String? + + public init( + accessToken: String, + refreshToken: String?, + expiresAt: Date?, + scopes: [String], + rateLimitTier: String?) + { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt + self.scopes = scopes + self.rateLimitTier = rateLimitTier + } + + public var isExpired: Bool { + guard let expiresAt else { return true } + return Date() >= expiresAt + } + + public var expiresIn: TimeInterval? { + guard let expiresAt else { return nil } + return expiresAt.timeIntervalSinceNow + } + + public static func parse(data: Data) throws -> ClaudeOAuthCredentials { + let decoder = JSONDecoder() + guard let root = try? decoder.decode(Root.self, from: data) else { + throw ClaudeOAuthCredentialsError.decodeFailed + } + guard let oauth = root.claudeAiOauth else { + throw ClaudeOAuthCredentialsError.missingOAuth + } + let accessToken = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !accessToken.isEmpty else { + throw ClaudeOAuthCredentialsError.missingAccessToken + } + let expiresAt = oauth.expiresAt.map { millis in + Date(timeIntervalSince1970: millis / 1000.0) + } + return ClaudeOAuthCredentials( + accessToken: accessToken, + refreshToken: oauth.refreshToken, + expiresAt: expiresAt, + scopes: oauth.scopes ?? [], + rateLimitTier: oauth.rateLimitTier) + } + + private struct Root: Decodable { + let claudeAiOauth: OAuth? + } + + private struct OAuth: Decodable { + let accessToken: String? + let refreshToken: String? + let expiresAt: Double? + let scopes: [String]? + let rateLimitTier: String? + + enum CodingKeys: String, CodingKey { + case accessToken + case refreshToken + case expiresAt + case scopes + case rateLimitTier + } + } +} + +extension ClaudeOAuthCredentials { + func diagnosticsMetadata(now: Date = Date()) -> [String: String] { + let hasRefreshToken = !(self.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let hasUserProfileScope = self.scopes.contains("user:profile") + + var metadata: [String: String] = [ + "hasRefreshToken": "\(hasRefreshToken)", + "scopesCount": "\(self.scopes.count)", + "hasUserProfileScope": "\(hasUserProfileScope)", + ] + + if let expiresAt = self.expiresAt { + let expiresAtMs = Int(expiresAt.timeIntervalSince1970 * 1000.0) + let expiresInSec = Int(expiresAt.timeIntervalSince(now).rounded()) + metadata["expiresAtMs"] = "\(expiresAtMs)" + metadata["expiresInSec"] = "\(expiresInSec)" + metadata["isExpired"] = "\(now >= expiresAt)" + } else { + metadata["expiresAtMs"] = "nil" + metadata["expiresInSec"] = "nil" + metadata["isExpired"] = "true" + } + + return metadata + } +} + +public enum ClaudeOAuthCredentialOwner: String, Codable, Sendable { + case claudeCLI + case codexbar + case environment +} + +public enum ClaudeOAuthCredentialSource: String, Sendable { + case environment + case memoryCache + case cacheKeychain + case credentialsFile + case claudeKeychain +} + +public struct ClaudeOAuthCredentialRecord: Sendable { + public let credentials: ClaudeOAuthCredentials + public let owner: ClaudeOAuthCredentialOwner + public let source: ClaudeOAuthCredentialSource + + public init( + credentials: ClaudeOAuthCredentials, + owner: ClaudeOAuthCredentialOwner, + source: ClaudeOAuthCredentialSource) + { + self.credentials = credentials + self.owner = owner + self.source = source + } +} + +public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { + case decodeFailed + case missingOAuth + case missingAccessToken + case notFound + case keychainError(Int) + case readFailed(String) + case refreshFailed(String) + case noRefreshToken + case refreshDelegatedToClaudeCLI + + public var errorDescription: String? { + switch self { + case .decodeFailed: + return "Claude OAuth credentials are invalid." + case .missingOAuth: + return "Claude OAuth credentials missing. Run `claude` to authenticate." + case .missingAccessToken: + return "Claude OAuth access token missing. Run `claude` to authenticate." + case .notFound: + return "Claude OAuth credentials not found. Run `claude` to authenticate." + case let .keychainError(status): + #if os(macOS) + if status == Int(errSecUserCanceled) + || status == Int(errSecAuthFailed) + || status == Int(errSecInteractionNotAllowed) + || status == Int(errSecNoAccessForItem) + { + return "Claude Keychain access was denied. CodexBar will back off in the background until you retry " + + "via a user action (menu open / manual refresh). " + + "Switch Claude Usage source to Web/CLI, or allow access in Keychain Access." + } + #endif + return "Claude OAuth keychain error: \(status)" + case let .readFailed(message): + return "Claude OAuth credentials read failed: \(message)" + case let .refreshFailed(message): + return "Claude OAuth token refresh failed: \(message)" + case .noRefreshToken: + return "Claude OAuth refresh token missing. Run `claude` to authenticate." + case .refreshDelegatedToClaudeCLI: + return "Claude OAuth refresh is delegated to Claude CLI." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+Hashing.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+Hashing.swift new file mode 100644 index 000000000..5e836192d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+Hashing.swift @@ -0,0 +1,18 @@ +import Foundation + +#if canImport(CryptoKit) +import CryptoKit +#endif + +extension ClaudeOAuthCredentialsStore { + static func sha256Prefix(_ data: Data) -> String? { + #if canImport(CryptoKit) + let digest = SHA256.hash(data: data) + let hex = digest.compactMap { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) + #else + _ = data + return nil + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift new file mode 100644 index 000000000..72cb6dd96 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -0,0 +1,262 @@ +import Dispatch +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +extension ClaudeOAuthCredentialsStore { + private static let securityBinaryPath = "/usr/bin/security" + private static let securityCLIReadTimeout: TimeInterval = 1.5 + + struct SecurityCLIReadRequest: Sendable { + let account: String? + } + + static func shouldPreferSecurityCLIKeychainRead( + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + -> Bool + { + readStrategy == .securityCLIExperimental + } + + #if os(macOS) + private enum SecurityCLIReadError: Error, Sendable { + case binaryUnavailable + case launchFailed + case timedOut + case nonZeroExit(status: Int32, stderrLength: Int) + } + + private struct SecurityCLIReadCommandResult: Sendable { + let status: Int32 + let stdout: Data + let stderrLength: Int + let durationMs: Double + } + + /// Attempts a Claude keychain read via `/usr/bin/security` when the experimental reader is enabled. + /// - Important: `interaction` is diagnostics context only and does not gate CLI execution. + static func loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteraction, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + -> Data? + { + guard self.shouldPreferSecurityCLIKeychainRead(readStrategy: readStrategy) else { return nil } + let interactionMetadata = interaction == .userInitiated ? "user" : "background" + + do { + let preferredAccount = self.preferredClaudeKeychainAccountForSecurityCLIRead( + interaction: interaction) + let output: Data + let status: Int32 + let stderrLength: Int + let durationMs: Double + #if DEBUG + if let override = self.taskSecurityCLIReadOverride ?? self.securityCLIReadOverride { + switch override { + case let .data(data): + output = data ?? Data() + status = 0 + stderrLength = 0 + durationMs = 0 + case .timedOut: + throw SecurityCLIReadError.timedOut + case .nonZeroExit: + throw SecurityCLIReadError.nonZeroExit(status: 1, stderrLength: 0) + case let .dynamic(read): + output = read(SecurityCLIReadRequest(account: preferredAccount)) ?? Data() + status = 0 + stderrLength = 0 + durationMs = 0 + } + } else { + let result = try self.runClaudeSecurityCLIRead( + timeout: self.securityCLIReadTimeout, + account: preferredAccount) + output = result.stdout + status = result.status + stderrLength = result.stderrLength + durationMs = result.durationMs + } + #else + let result = try self.runClaudeSecurityCLIRead( + timeout: self.securityCLIReadTimeout, + account: preferredAccount) + output = result.stdout + status = result.status + stderrLength = result.stderrLength + durationMs = result.durationMs + #endif + + let sanitized = self.sanitizeSecurityCLIOutput(output) + guard !sanitized.isEmpty else { return nil } + let parsedCredentials: ClaudeOAuthCredentials + do { + parsedCredentials = try ClaudeOAuthCredentials.parse(data: sanitized) + } catch { + self.log.warning( + "Claude keychain security CLI output invalid; falling back", + metadata: [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "status": "\(status)", + "duration_ms": String(format: "%.2f", durationMs), + "stderr_length": "\(stderrLength)", + "payload_bytes": "\(sanitized.count)", + "parse_error_type": String(describing: type(of: error)), + ]) + return nil + } + + var metadata: [String: String] = [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "status": "\(status)", + "duration_ms": String(format: "%.2f", durationMs), + "stderr_length": "\(stderrLength)", + "payload_bytes": "\(sanitized.count)", + "accountPinned": preferredAccount == nil ? "0" : "1", + ] + for (key, value) in parsedCredentials.diagnosticsMetadata(now: Date()) { + metadata[key] = value + } + self.log.debug( + "Claude keychain security CLI read succeeded", + metadata: metadata) + return sanitized + } catch let error as SecurityCLIReadError { + var metadata: [String: String] = [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "error_type": String(describing: type(of: error)), + ] + switch error { + case .binaryUnavailable: + metadata["reason"] = "binaryUnavailable" + case .launchFailed: + metadata["reason"] = "launchFailed" + case .timedOut: + metadata["reason"] = "timedOut" + case let .nonZeroExit(status, stderrLength): + metadata["reason"] = "nonZeroExit" + metadata["status"] = "\(status)" + metadata["stderr_length"] = "\(stderrLength)" + } + self.log.warning("Claude keychain security CLI read failed; falling back", metadata: metadata) + return nil + } catch { + self.log.warning( + "Claude keychain security CLI read failed; falling back", + metadata: [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "error_type": String(describing: type(of: error)), + ]) + return nil + } + } + + private static func sanitizeSecurityCLIOutput(_ data: Data) -> Data { + var sanitized = data + while let last = sanitized.last, last == 0x0A || last == 0x0D { + sanitized.removeLast() + } + return sanitized + } + + private static func runClaudeSecurityCLIRead( + timeout: TimeInterval, + account: String?) throws -> SecurityCLIReadCommandResult + { + guard FileManager.default.isExecutableFile(atPath: self.securityBinaryPath) else { + throw SecurityCLIReadError.binaryUnavailable + } + + var arguments = [ + "find-generic-password", + "-s", + self.claudeKeychainService, + ] + if let account, !account.isEmpty { + arguments.append(contentsOf: ["-a", account]) + } + arguments.append("-w") + + let process = Process() + process.executableURL = URL(fileURLWithPath: self.securityBinaryPath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.standardInput = nil + + let startedAt = DispatchTime.now().uptimeNanoseconds + do { + try process.run() + } catch { + throw SecurityCLIReadError.launchFailed + } + + var processGroup: pid_t? + let pid = process.processIdentifier + if setpgid(pid, pid) == 0 { + processGroup = pid + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + + if process.isRunning { + self.terminate(process: process, processGroup: processGroup) + throw SecurityCLIReadError.timedOut + } + + let stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let status = process.terminationStatus + let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAt) / 1_000_000.0 + guard status == 0 else { + throw SecurityCLIReadError.nonZeroExit(status: status, stderrLength: stderr.count) + } + + return SecurityCLIReadCommandResult( + status: status, + stdout: stdout, + stderrLength: stderr.count, + durationMs: durationMs) + } + + private static func terminate(process: Process, processGroup: pid_t?) { + guard process.isRunning else { return } + process.terminate() + if let processGroup { + kill(-processGroup, SIGTERM) + } + let deadline = Date().addingTimeInterval(0.4) + while process.isRunning, Date() < deadline { + usleep(50000) + } + if process.isRunning { + if let processGroup { + kill(-processGroup, SIGKILL) + } + kill(process.processIdentifier, SIGKILL) + } + } + #else + static func loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction _: ProviderInteraction, + readStrategy _: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + -> Data? + { + nil + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift new file mode 100644 index 000000000..dbcd11105 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -0,0 +1,212 @@ +import Foundation + +#if DEBUG +extension ClaudeOAuthCredentialsStore { + nonisolated(unsafe) static var claudeKeychainDataOverride: Data? + nonisolated(unsafe) static var claudeKeychainFingerprintOverride: ClaudeKeychainFingerprint? + @TaskLocal static var taskClaudeKeychainDataOverride: Data? + @TaskLocal static var taskClaudeKeychainFingerprintOverride: ClaudeKeychainFingerprint? + @TaskLocal static var taskMemoryCacheStoreOverride: MemoryCacheStore? + @TaskLocal static var taskClaudeKeychainFingerprintStoreOverride: ClaudeKeychainFingerprintStore? + + final class ClaudeKeychainFingerprintStore: @unchecked Sendable { + var fingerprint: ClaudeKeychainFingerprint? + + init(fingerprint: ClaudeKeychainFingerprint? = nil) { + self.fingerprint = fingerprint + } + } + + final class MemoryCacheStore: @unchecked Sendable { + var record: ClaudeOAuthCredentialRecord? + var timestamp: Date? + } + + static func setClaudeKeychainDataOverrideForTesting(_ data: Data?) { + self.claudeKeychainDataOverride = data + } + + static func setClaudeKeychainFingerprintOverrideForTesting(_ fingerprint: ClaudeKeychainFingerprint?) { + self.claudeKeychainFingerprintOverride = fingerprint + } + + static func withClaudeKeychainOverridesForTesting( + data: Data?, + fingerprint: ClaudeKeychainFingerprint?, + operation: () throws -> T) rethrows -> T + { + try self.$taskClaudeKeychainDataOverride.withValue(data) { + try self.$taskClaudeKeychainFingerprintOverride.withValue(fingerprint) { + try operation() + } + } + } + + static func withClaudeKeychainOverridesForTesting( + data: Data?, + fingerprint: ClaudeKeychainFingerprint?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskClaudeKeychainDataOverride.withValue(data) { + try await self.$taskClaudeKeychainFingerprintOverride.withValue(fingerprint) { + try await operation() + } + } + } + + static func withClaudeKeychainFingerprintStoreOverrideForTesting( + _ store: ClaudeKeychainFingerprintStore?, + operation: () throws -> T) rethrows -> T + { + try self.$taskClaudeKeychainFingerprintStoreOverride.withValue(store) { + try operation() + } + } + + static func withClaudeKeychainFingerprintStoreOverrideForTesting( + _ store: ClaudeKeychainFingerprintStore?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskClaudeKeychainFingerprintStoreOverride.withValue(store) { + try await operation() + } + } + + static func withIsolatedMemoryCacheForTesting(operation: () throws -> T) rethrows -> T { + let store = MemoryCacheStore() + return try self.$taskMemoryCacheStoreOverride.withValue(store) { + try operation() + } + } + + static func withIsolatedMemoryCacheForTesting(operation: () async throws -> T) async rethrows -> T { + let store = MemoryCacheStore() + return try await self.$taskMemoryCacheStoreOverride.withValue(store) { + try await operation() + } + } + + final class CredentialsFileFingerprintStore: @unchecked Sendable { + var fingerprint: CredentialsFileFingerprint? + + init(fingerprint: CredentialsFileFingerprint? = nil) { + self.fingerprint = fingerprint + } + + func load() -> CredentialsFileFingerprint? { + self.fingerprint + } + + func save(_ fingerprint: CredentialsFileFingerprint?) { + self.fingerprint = fingerprint + } + } + + enum SecurityCLIReadOverride: Sendable { + case data(Data?) + case timedOut + case nonZeroExit + case dynamic(@Sendable (SecurityCLIReadRequest) -> Data?) + } + + @TaskLocal static var taskKeychainAccessOverride: Bool? + @TaskLocal static var taskCredentialsFileFingerprintStoreOverride: CredentialsFileFingerprintStore? + @TaskLocal static var taskSecurityCLIReadOverride: SecurityCLIReadOverride? + @TaskLocal static var taskSecurityCLIReadAccountOverride: String? + nonisolated(unsafe) static var securityCLIReadOverride: SecurityCLIReadOverride? + + static func withKeychainAccessOverrideForTesting( + _ disabled: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$taskKeychainAccessOverride.withValue(disabled) { + try operation() + } + } + + static func withKeychainAccessOverrideForTesting( + _ disabled: Bool?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskKeychainAccessOverride.withValue(disabled) { + try await operation() + } + } + + fileprivate static func withCredentialsFileFingerprintStoreOverrideForTesting( + _ store: CredentialsFileFingerprintStore?, + operation: () throws -> T) rethrows -> T + { + try self.$taskCredentialsFileFingerprintStoreOverride.withValue(store) { + try operation() + } + } + + fileprivate static func withCredentialsFileFingerprintStoreOverrideForTesting( + _ store: CredentialsFileFingerprintStore?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskCredentialsFileFingerprintStoreOverride.withValue(store) { + try await operation() + } + } + + static func withIsolatedCredentialsFileTrackingForTesting( + operation: () throws -> T) rethrows -> T + { + let store = CredentialsFileFingerprintStore() + return try self.$taskCredentialsFileFingerprintStoreOverride.withValue(store) { + try operation() + } + } + + static func withIsolatedCredentialsFileTrackingForTesting( + operation: () async throws -> T) async rethrows -> T + { + let store = CredentialsFileFingerprintStore() + return try await self.$taskCredentialsFileFingerprintStoreOverride.withValue(store) { + try await operation() + } + } + + static func withSecurityCLIReadOverrideForTesting( + _ readOverride: SecurityCLIReadOverride?, + operation: () throws -> T) rethrows -> T + { + try self.$taskSecurityCLIReadOverride.withValue(readOverride) { + try operation() + } + } + + static func withSecurityCLIReadOverrideForTesting( + _ readOverride: SecurityCLIReadOverride?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskSecurityCLIReadOverride.withValue(readOverride) { + try await operation() + } + } + + static func withSecurityCLIReadAccountOverrideForTesting( + _ account: String?, + operation: () throws -> T) rethrows -> T + { + try self.$taskSecurityCLIReadAccountOverride.withValue(account) { + try operation() + } + } + + static func withSecurityCLIReadAccountOverrideForTesting( + _ account: String?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskSecurityCLIReadAccountOverride.withValue(account) { + try await operation() + } + } + + static func setSecurityCLIReadOverrideForTesting(_ readOverride: SecurityCLIReadOverride?) { + self.securityCLIReadOverride = readOverride + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index c8fa63d7d..e3e52c51f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -5,170 +5,15 @@ import Foundation import FoundationNetworking #endif -#if canImport(CryptoKit) -import CryptoKit -#endif - #if os(macOS) import LocalAuthentication import Security #endif -public struct ClaudeOAuthCredentials: Sendable { - public let accessToken: String - public let refreshToken: String? - public let expiresAt: Date? - public let scopes: [String] - public let rateLimitTier: String? - - public init( - accessToken: String, - refreshToken: String?, - expiresAt: Date?, - scopes: [String], - rateLimitTier: String?) - { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.expiresAt = expiresAt - self.scopes = scopes - self.rateLimitTier = rateLimitTier - } - - public var isExpired: Bool { - guard let expiresAt else { return true } - return Date() >= expiresAt - } - - public var expiresIn: TimeInterval? { - guard let expiresAt else { return nil } - return expiresAt.timeIntervalSinceNow - } - - public static func parse(data: Data) throws -> ClaudeOAuthCredentials { - let decoder = JSONDecoder() - guard let root = try? decoder.decode(Root.self, from: data) else { - throw ClaudeOAuthCredentialsError.decodeFailed - } - guard let oauth = root.claudeAiOauth else { - throw ClaudeOAuthCredentialsError.missingOAuth - } - let accessToken = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !accessToken.isEmpty else { - throw ClaudeOAuthCredentialsError.missingAccessToken - } - let expiresAt = oauth.expiresAt.map { millis in - Date(timeIntervalSince1970: millis / 1000.0) - } - return ClaudeOAuthCredentials( - accessToken: accessToken, - refreshToken: oauth.refreshToken, - expiresAt: expiresAt, - scopes: oauth.scopes ?? [], - rateLimitTier: oauth.rateLimitTier) - } - - private struct Root: Decodable { - let claudeAiOauth: OAuth? - } - - private struct OAuth: Decodable { - let accessToken: String? - let refreshToken: String? - let expiresAt: Double? - let scopes: [String]? - let rateLimitTier: String? - - enum CodingKeys: String, CodingKey { - case accessToken - case refreshToken - case expiresAt - case scopes - case rateLimitTier - } - } -} - -public enum ClaudeOAuthCredentialOwner: String, Codable, Sendable { - case claudeCLI - case codexbar - case environment -} - -public enum ClaudeOAuthCredentialSource: String, Sendable { - case environment - case memoryCache - case cacheKeychain - case credentialsFile - case claudeKeychain -} - -public struct ClaudeOAuthCredentialRecord: Sendable { - public let credentials: ClaudeOAuthCredentials - public let owner: ClaudeOAuthCredentialOwner - public let source: ClaudeOAuthCredentialSource - - public init( - credentials: ClaudeOAuthCredentials, - owner: ClaudeOAuthCredentialOwner, - source: ClaudeOAuthCredentialSource) - { - self.credentials = credentials - self.owner = owner - self.source = source - } -} - -public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { - case decodeFailed - case missingOAuth - case missingAccessToken - case notFound - case keychainError(Int) - case readFailed(String) - case refreshFailed(String) - case noRefreshToken - case refreshDelegatedToClaudeCLI - - public var errorDescription: String? { - switch self { - case .decodeFailed: - return "Claude OAuth credentials are invalid." - case .missingOAuth: - return "Claude OAuth credentials missing. Run `claude` to authenticate." - case .missingAccessToken: - return "Claude OAuth access token missing. Run `claude` to authenticate." - case .notFound: - return "Claude OAuth credentials not found. Run `claude` to authenticate." - case let .keychainError(status): - #if os(macOS) - if status == Int(errSecUserCanceled) - || status == Int(errSecAuthFailed) - || status == Int(errSecInteractionNotAllowed) - || status == Int(errSecNoAccessForItem) - { - return "Claude Keychain access was denied. CodexBar will back off in the background until you retry " - + "via a user action (menu open / manual refresh). " - + "Switch Claude Usage source to Web/CLI, or allow access in Keychain Access." - } - #endif - return "Claude OAuth keychain error: \(status)" - case let .readFailed(message): - return "Claude OAuth credentials read failed: \(message)" - case let .refreshFailed(message): - return "Claude OAuth token refresh failed: \(message)" - case .noRefreshToken: - return "Claude OAuth refresh token missing. Run `claude` to authenticate." - case .refreshDelegatedToClaudeCLI: - return "Claude OAuth refresh is delegated to Claude CLI." - } - } -} - // swiftlint:disable type_body_length public enum ClaudeOAuthCredentialsStore { private static let credentialsPath = ".claude/.credentials.json" - private static let claudeKeychainService = "Claude Code-credentials" + static let claudeKeychainService = "Claude Code-credentials" private static let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) public static let environmentTokenKey = "CODEXBAR_CLAUDE_OAUTH_TOKEN" public static let environmentScopesKey = "CODEXBAR_CLAUDE_OAUTH_SCOPES" @@ -186,7 +31,7 @@ public enum ClaudeOAuthCredentialsStore { ?? self.defaultOAuthClientID } - private static let log = CodexBarLog.logger(LogCategories.claudeUsage) + static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV2" private static let claudeKeychainPromptLock = NSLock() private static let claudeKeychainFingerprintKey = "ClaudeOAuthClaudeKeychainFingerprintV2" @@ -202,97 +47,7 @@ public enum ClaudeOAuthCredentialsStore { let persistentRefHash: String? } - #if DEBUG - private nonisolated(unsafe) static var keychainAccessOverride: Bool? - private nonisolated(unsafe) static var claudeKeychainDataOverride: Data? - private nonisolated(unsafe) static var claudeKeychainFingerprintOverride: ClaudeKeychainFingerprint? - @TaskLocal private static var taskClaudeKeychainDataOverride: Data? - @TaskLocal private static var taskClaudeKeychainFingerprintOverride: ClaudeKeychainFingerprint? - @TaskLocal private static var taskMemoryCacheStoreOverride: MemoryCacheStore? - final class ClaudeKeychainFingerprintStore: @unchecked Sendable { - var fingerprint: ClaudeKeychainFingerprint? - - init(fingerprint: ClaudeKeychainFingerprint? = nil) { - self.fingerprint = fingerprint - } - } - - final class MemoryCacheStore: @unchecked Sendable { - var record: ClaudeOAuthCredentialRecord? - var timestamp: Date? - } - - @TaskLocal private static var taskClaudeKeychainFingerprintStoreOverride: ClaudeKeychainFingerprintStore? - static func setKeychainAccessOverrideForTesting(_ disabled: Bool?) { - self.keychainAccessOverride = disabled - } - - static func setClaudeKeychainDataOverrideForTesting(_ data: Data?) { - self.claudeKeychainDataOverride = data - } - - static func setClaudeKeychainFingerprintOverrideForTesting(_ fingerprint: ClaudeKeychainFingerprint?) { - self.claudeKeychainFingerprintOverride = fingerprint - } - - static func withClaudeKeychainOverridesForTesting( - data: Data?, - fingerprint: ClaudeKeychainFingerprint?, - operation: () throws -> T) rethrows -> T - { - try self.$taskClaudeKeychainDataOverride.withValue(data) { - try self.$taskClaudeKeychainFingerprintOverride.withValue(fingerprint) { - try operation() - } - } - } - - static func withClaudeKeychainOverridesForTesting( - data: Data?, - fingerprint: ClaudeKeychainFingerprint?, - operation: () async throws -> T) async rethrows -> T - { - try await self.$taskClaudeKeychainDataOverride.withValue(data) { - try await self.$taskClaudeKeychainFingerprintOverride.withValue(fingerprint) { - try await operation() - } - } - } - - static func withClaudeKeychainFingerprintStoreOverrideForTesting( - _ store: ClaudeKeychainFingerprintStore?, - operation: () throws -> T) rethrows -> T - { - try self.$taskClaudeKeychainFingerprintStoreOverride.withValue(store) { - try operation() - } - } - - static func withClaudeKeychainFingerprintStoreOverrideForTesting( - _ store: ClaudeKeychainFingerprintStore?, - operation: () async throws -> T) async rethrows -> T - { - try await self.$taskClaudeKeychainFingerprintStoreOverride.withValue(store) { - try await operation() - } - } - - static func withIsolatedMemoryCacheForTesting(operation: () throws -> T) rethrows -> T { - let store = MemoryCacheStore() - return try self.$taskMemoryCacheStoreOverride.withValue(store) { - try operation() - } - } - - static func withIsolatedMemoryCacheForTesting(operation: () async throws -> T) async rethrows -> T { - let store = MemoryCacheStore() - return try await self.$taskMemoryCacheStoreOverride.withValue(store) { - try await operation() - } - } - #endif - - private struct CredentialsFileFingerprint: Codable, Equatable, Sendable { + struct CredentialsFileFingerprint: Codable, Equatable, Sendable { let modifiedAtMs: Int? let size: Int } @@ -313,6 +68,7 @@ public enum ClaudeOAuthCredentialsStore { #if DEBUG @TaskLocal private static var taskCredentialsURLOverride: URL? #endif + @TaskLocal static var allowBackgroundPromptBootstrap: Bool = false // In-memory cache (nonisolated for synchronous access) private static let memoryCacheLock = NSLock() private nonisolated(unsafe) static var cachedCredentialRecord: ClaudeOAuthCredentialRecord? @@ -498,9 +254,10 @@ public enum ClaudeOAuthCredentialsStore { respectKeychainPromptCooldown: Bool, lastError: inout Error?) -> ClaudeOAuthCredentialRecord? { + let shouldApplyPromptCooldown = self.isPromptPolicyApplicable && respectKeychainPromptCooldown let promptAllowed = allowKeychainPrompt - && (!respectKeychainPromptCooldown || ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()) + && (!shouldApplyPromptCooldown || ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()) guard promptAllowed else { return nil } do { @@ -530,16 +287,63 @@ public enum ClaudeOAuthCredentialsStore { source: .cacheKeychain) } + let promptMode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } + + if self.shouldPreferSecurityCLIKeychainRead(), + let keychainData = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current) + { + let creds = try ClaudeOAuthCredentials.parse(data: keychainData) + let record = ClaudeOAuthCredentialRecord( + credentials: creds, + owner: .claudeCLI, + source: .claudeKeychain) + self.writeMemoryCache( + record: ClaudeOAuthCredentialRecord( + credentials: creds, + owner: .claudeCLI, + source: .memoryCache), + timestamp: Date()) + self.saveToCacheKeychain(keychainData, owner: .claudeCLI) + return record + } + + let shouldPreferSecurityCLIKeychainRead = self.shouldPreferSecurityCLIKeychainRead() + var fallbackPromptMode = promptMode + if shouldPreferSecurityCLIKeychainRead { + fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + let fallbackDecision = self.securityFrameworkFallbackPromptDecision( + promptMode: fallbackPromptMode, + allowKeychainPrompt: allowKeychainPrompt, + respectKeychainPromptCooldown: respectKeychainPromptCooldown) + self.log.debug( + "Claude keychain Security.framework fallback prompt policy evaluated", + metadata: [ + "reader": "securityFrameworkFallback", + "fallbackPromptMode": fallbackPromptMode.rawValue, + "fallbackPromptAllowed": "\(fallbackDecision.allowed)", + "fallbackBlockedReason": fallbackDecision.blockedReason ?? "none", + ]) + guard fallbackDecision.allowed else { return nil } + } + // Some macOS configurations still show the system keychain prompt even for our "silent" probes. // Only show the in-app pre-alert when we have evidence that Keychain interaction is likely. if self.shouldShowClaudeKeychainPreAlert() { - KeychainPromptHandler.handler?( + KeychainPromptHandler.notify( KeychainPromptContext( kind: .claudeOAuth, service: self.claudeKeychainService, account: nil)) } - let keychainData = try self.loadFromClaudeKeychain() + let keychainData: Data = if shouldPreferSecurityCLIKeychainRead { + try self.loadFromClaudeKeychainUsingSecurityFramework( + promptMode: fallbackPromptMode, + allowKeychainPrompt: true) + } else { + try self.loadFromClaudeKeychain() + } let creds = try ClaudeOAuthCredentials.parse(data: keychainData) let record = ClaudeOAuthCredentialRecord( credentials: creds, @@ -578,17 +382,33 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown) let credentials = record.credentials + let now = Date() + var expiryMetadata = credentials.diagnosticsMetadata(now: now) + expiryMetadata["source"] = record.source.rawValue + expiryMetadata["owner"] = record.owner.rawValue + expiryMetadata["allowKeychainPrompt"] = "\(allowKeychainPrompt)" + expiryMetadata["respectPromptCooldown"] = "\(respectKeychainPromptCooldown)" + expiryMetadata["readStrategy"] = ClaudeOAuthKeychainReadStrategyPreference.current().rawValue + + let isExpired: Bool = if let expiresAt = credentials.expiresAt { + now >= expiresAt + } else { + true + } // If not expired, return as-is - guard credentials.isExpired else { + guard isExpired else { + self.log.debug("Claude OAuth credentials loaded for usage", metadata: expiryMetadata) return credentials } + self.log.info("Claude OAuth credentials considered expired", metadata: expiryMetadata) + switch record.owner { case .claudeCLI: self.log.info( "Claude OAuth credentials expired; delegating refresh to Claude CLI", - metadata: ["source": record.source.rawValue]) + metadata: expiryMetadata) throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI case .environment: self.log.warning("Environment OAuth token expired and cannot be auto-refreshed") @@ -783,9 +603,28 @@ public enum ClaudeOAuthCredentialsStore { public static func hasClaudeKeychainCredentialsWithoutPrompt() -> Bool { #if os(macOS) - if !self.keychainAccessAllowed { return false } + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } + if self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current) != nil + { + return true + } + + let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { return false } if ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return false } + #if DEBUG + if let store = self.taskClaudeKeychainOverrideStore, + let data = store.data + { + return (try? ClaudeOAuthCredentials.parse(data: data)) != nil + } + if let data = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride { + return (try? ClaudeOAuthCredentials.parse(data: data)) != nil + } + #endif var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -819,8 +658,10 @@ public enum ClaudeOAuthCredentialsStore { now: Date = Date()) -> ClaudeOAuthCredentialRecord? { #if os(macOS) - if !self.keychainAccessAllowed { return nil } - if respectKeychainPromptCooldown, + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } + if self.isPromptPolicyApplicable, + respectKeychainPromptCooldown, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) { return nil @@ -897,7 +738,9 @@ public enum ClaudeOAuthCredentialsStore { guard cached.owner == .claudeCLI else { return false } guard self.keychainAccessAllowed else { return false } - let mode = ClaudeOAuthKeychainPromptPreference.current() + // Freshness sync is opportunistic and may perform Security.framework fingerprint checks. + // Keep this gated by the user's stored prompt policy even in experimental reader mode. + let mode = ClaudeOAuthKeychainPromptPreference.storedMode() switch mode { case .never: return false @@ -921,7 +764,8 @@ public enum ClaudeOAuthCredentialsStore { respectKeychainPromptCooldown: Bool) -> ClaudeOAuthCredentialRecord? { #if os(macOS) - if !self.keychainAccessAllowed { return nil } + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } // If Keychain preflight indicates interaction is likely, skip the silent repair read. // Why: non-interactive probes can still show UI on some systems, and if interaction is required we should @@ -930,7 +774,8 @@ public enum ClaudeOAuthCredentialsStore { return nil } - if respectKeychainPromptCooldown, + if self.isPromptPolicyApplicable, + respectKeychainPromptCooldown, ProviderInteractionContext.current != .userInitiated, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) { @@ -938,6 +783,30 @@ public enum ClaudeOAuthCredentialsStore { } do { + if self.shouldPreferSecurityCLIKeychainRead(), + let securityData = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current), + !securityData.isEmpty + { + // Keep CLI-success repair prompt-safe in experimental mode by avoiding Security.framework fingerprint + // probes, which can still show UI on some systems even for "no UI" queries. + guard let creds = try? ClaudeOAuthCredentials.parse(data: securityData) else { return nil } + if creds.isExpired { + return ClaudeOAuthCredentialRecord(credentials: creds, owner: .claudeCLI, source: .claudeKeychain) + } + + self.writeMemoryCache( + record: ClaudeOAuthCredentialRecord(credentials: creds, owner: .claudeCLI, source: .memoryCache), + timestamp: now) + self.saveToCacheKeychain(securityData, owner: .claudeCLI) + + self.log.info( + "Claude keychain credentials loaded without prompt; syncing OAuth cache", + metadata: ["interaction": ProviderInteractionContext + .current == .userInitiated ? "user" : "background"]) + return ClaudeOAuthCredentialRecord(credentials: creds, owner: .claudeCLI, source: .claudeKeychain) + } + guard let data = try self.loadFromClaudeKeychainNonInteractive(), !data.isEmpty else { return nil } let fingerprint = self.currentClaudeKeychainFingerprintWithoutPrompt() guard let creds = try? ClaudeOAuthCredentials.parse(data: data) else { @@ -1038,16 +907,14 @@ public enum ClaudeOAuthCredentialsStore { } private static func currentClaudeKeychainFingerprintWithoutPrompt() -> ClaudeKeychainFingerprint? { + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } #if DEBUG if let store = taskClaudeKeychainOverrideStore { return store.fingerprint } if let override = taskClaudeKeychainFingerprintOverride ?? self .claudeKeychainFingerprintOverride { return override } #endif #if os(macOS) - if !self.keychainAccessAllowed { - return nil - } - let newest: ClaudeKeychainCandidate? = self.claudeKeychainCandidatesWithoutPrompt().first ?? self.claudeKeychainLegacyCandidateWithoutPrompt() guard let newest else { return nil } @@ -1074,30 +941,26 @@ public enum ClaudeOAuthCredentialsStore { return "\(modifiedAt):\(fingerprint.size)" } - private static func sha256Prefix(_ data: Data) -> String? { - #if canImport(CryptoKit) - let digest = SHA256.hash(data: data) - let hex = digest.compactMap { String(format: "%02x", $0) }.joined() - return String(hex.prefix(12)) - #else - _ = data - return nil - #endif - } - private static func loadFromClaudeKeychainNonInteractive() throws -> Data? { + #if os(macOS) + let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current) + { + return data + } + + // For experimental strategy, enforce stored prompt policy before any Security.framework fallback probes. + guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { return nil } + #if DEBUG if let store = taskClaudeKeychainOverrideStore { return store.data } if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride { return override } #endif - #if os(macOS) - if !self.keychainAccessAllowed { - return nil - } // Keep semantics aligned with fingerprinting: if there are multiple entries, we only ever consult the newest // candidate (same as currentClaudeKeychainFingerprintWithoutPrompt()) to avoid syncing from a different item. - let candidates = self.claudeKeychainCandidatesWithoutPrompt() + let candidates = self.claudeKeychainCandidatesWithoutPrompt(promptMode: fallbackPromptMode) if let newest = candidates.first { if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: false), !data.isEmpty @@ -1107,11 +970,10 @@ public enum ClaudeOAuthCredentialsStore { return nil } - if let data = try self.loadClaudeKeychainLegacyData(allowKeychainPrompt: false), - !data.isEmpty - { - return data - } + let legacyData = try self.loadClaudeKeychainLegacyData( + allowKeychainPrompt: false, + promptMode: fallbackPromptMode) + if let legacyData, !legacyData.isEmpty { return legacyData } return nil #else return nil @@ -1119,18 +981,64 @@ public enum ClaudeOAuthCredentialsStore { } public static func loadFromClaudeKeychain() throws -> Data { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: ClaudeOAuthKeychainPromptPreference.current()) else { + throw ClaudeOAuthCredentialsError.notFound + } #if DEBUG - if let override = self.claudeKeychainDataOverride { return override } + if let store = taskClaudeKeychainOverrideStore, let override = store.data { return override } + if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride { return override } #endif - #if os(macOS) - if !self.keychainAccessAllowed { - throw ClaudeOAuthCredentialsError.notFound + if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current) + { + return data + } + if self.shouldPreferSecurityCLIKeychainRead() { + let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + let fallbackDecision = self.securityFrameworkFallbackPromptDecision( + promptMode: fallbackPromptMode, + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + self.log.debug( + "Claude keychain Security.framework fallback prompt policy evaluated", + metadata: [ + "reader": "securityFrameworkFallback", + "fallbackPromptMode": fallbackPromptMode.rawValue, + "fallbackPromptAllowed": "\(fallbackDecision.allowed)", + "fallbackBlockedReason": fallbackDecision.blockedReason ?? "none", + ]) + guard fallbackDecision.allowed else { + throw ClaudeOAuthCredentialsError.notFound + } + return try self.loadFromClaudeKeychainUsingSecurityFramework( + promptMode: fallbackPromptMode, + allowKeychainPrompt: true) } - let candidates = self.claudeKeychainCandidatesWithoutPrompt() + return try self.loadFromClaudeKeychainUsingSecurityFramework() + } + + /// Legacy alias for backward compatibility + public static func loadFromKeychain() throws -> Data { + try self.loadFromClaudeKeychain() + } + + private static func loadFromClaudeKeychainUsingSecurityFramework( + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current(), + allowKeychainPrompt: Bool = true) throws -> Data + { + #if DEBUG + if let store = taskClaudeKeychainOverrideStore, let override = store.data { return override } + if let override = taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride { return override } + #endif + #if os(macOS) + let candidates = self.claudeKeychainCandidatesWithoutPrompt(promptMode: promptMode) if let newest = candidates.first { do { - if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: true), - !data.isEmpty + if let data = try self.loadClaudeKeychainData( + candidate: newest, + allowKeychainPrompt: allowKeychainPrompt, + promptMode: promptMode), + !data.isEmpty { // Store fingerprint after a successful interactive read so we don't immediately try to // "sync" in the background (which can still show UI on some systems). @@ -1154,8 +1062,10 @@ public enum ClaudeOAuthCredentialsStore { // Fallback: legacy query (may pick an arbitrary duplicate). do { - if let data = try self.loadClaudeKeychainLegacyData(allowKeychainPrompt: true), - !data.isEmpty + if let data = try self.loadClaudeKeychainLegacyData( + allowKeychainPrompt: allowKeychainPrompt, + promptMode: promptMode), + !data.isEmpty { // Same as above: store fingerprint after interactive read to avoid background "sync" reads. self.saveClaudeKeychainFingerprint(self.currentClaudeKeychainFingerprintWithoutPrompt()) @@ -1173,11 +1083,6 @@ public enum ClaudeOAuthCredentialsStore { #endif } - /// Legacy alias for backward compatibility - public static func loadFromKeychain() throws -> Data { - try self.loadFromClaudeKeychain() - } - #if os(macOS) private struct ClaudeKeychainCandidate: Sendable { let persistentRef: Data @@ -1186,8 +1091,13 @@ public enum ClaudeOAuthCredentialsStore { let createdAt: Date? } - private static func claudeKeychainCandidatesWithoutPrompt() -> [ClaudeKeychainCandidate] { - if ProviderInteractionContext.current == .background, + private static func claudeKeychainCandidatesWithoutPrompt( + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference + .current()) -> [ClaudeKeychainCandidate] + { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return [] } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return [] } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -1223,8 +1133,13 @@ public enum ClaudeOAuthCredentialsStore { } } - private static func claudeKeychainLegacyCandidateWithoutPrompt() -> ClaudeKeychainCandidate? { - if ProviderInteractionContext.current == .background, + private static func claudeKeychainLegacyCandidateWithoutPrompt( + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference + .current()) -> ClaudeKeychainCandidate? + { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return nil } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -1253,8 +1168,10 @@ public enum ClaudeOAuthCredentialsStore { private static func loadClaudeKeychainData( candidate: ClaudeKeychainCandidate, - allowKeychainPrompt: Bool) throws -> Data? + allowKeychainPrompt: Bool, + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data? { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } self.log.debug( "Claude keychain data read start", metadata: [ @@ -1312,7 +1229,11 @@ public enum ClaudeOAuthCredentialsStore { } } - private static func loadClaudeKeychainLegacyData(allowKeychainPrompt: Bool) throws -> Data? { + private static func loadClaudeKeychainLegacyData( + allowKeychainPrompt: Bool, + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data? + { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } self.log.debug( "Claude keychain legacy data read start", metadata: [ @@ -1432,13 +1353,86 @@ public enum ClaudeOAuthCredentialsStore { private static var keychainAccessAllowed: Bool { #if DEBUG - if let override = self.keychainAccessOverride { - return !override - } + if let override = self.taskKeychainAccessOverride { return !override } #endif return !KeychainAccessGate.isDisabled } + private static var isPromptPolicyApplicable: Bool { + ClaudeOAuthKeychainPromptPreference.isApplicable() + } + + private static func securityFrameworkFallbackPromptDecision( + promptMode: ClaudeOAuthKeychainPromptMode, + allowKeychainPrompt: Bool, + respectKeychainPromptCooldown: Bool) -> (allowed: Bool, blockedReason: String?) + { + guard allowKeychainPrompt else { + return (allowed: false, blockedReason: "allowKeychainPromptFalse") + } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { + return (allowed: false, blockedReason: self.fallbackBlockedReason(promptMode: promptMode)) + } + if respectKeychainPromptCooldown, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() + { + return (allowed: false, blockedReason: "cooldown") + } + return (allowed: true, blockedReason: nil) + } + + private static func fallbackBlockedReason(promptMode: ClaudeOAuthKeychainPromptMode) -> String { + if !self.keychainAccessAllowed { return "keychainDisabled" } + switch promptMode { + case .never: + return "never" + case .onlyOnUserAction: + return "onlyOnUserAction-background" + case .always: + return "disallowed" + } + } + + private static func shouldAllowClaudeCodeKeychainAccess( + mode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) -> Bool + { + guard self.keychainAccessAllowed else { return false } + switch mode { + case .never: return false + case .onlyOnUserAction: + return ProviderInteractionContext.current == .userInitiated || self.allowBackgroundPromptBootstrap + case .always: return true + } + } + + static func preferredClaudeKeychainAccountForSecurityCLIRead( + interaction: ProviderInteraction = ProviderInteractionContext.current) -> String? + { + // Keep the experimental background path fully on /usr/bin/security by default. + // Account pinning requires Security.framework candidate probing, so only allow it on explicit user actions. + guard interaction == .userInitiated else { return nil } + #if DEBUG + if let override = self.taskSecurityCLIReadAccountOverride { return override } + #endif + #if os(macOS) + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } + // Keep experimental mode prompt-safe: avoid Security.framework candidate probes when preflight says + // interaction is likely. + if self.shouldShowClaudeKeychainPreAlert() { + return nil + } + guard let account = self.claudeKeychainCandidatesWithoutPrompt(promptMode: mode).first?.account, + !account.isEmpty + else { + return nil + } + return account + #else + return nil + #endif + } + private static func credentialsFileURL() -> URL { #if DEBUG if let override = self.taskCredentialsURLOverride { return override } @@ -1447,6 +1441,9 @@ public enum ClaudeOAuthCredentialsStore { } private static func loadFileFingerprint() -> CredentialsFileFingerprint? { + #if DEBUG + if let store = self.taskCredentialsFileFingerprintStoreOverride { return store.load() } + #endif guard let data = UserDefaults.standard.data(forKey: self.fileFingerprintKey) else { return nil } @@ -1454,6 +1451,9 @@ public enum ClaudeOAuthCredentialsStore { } private static func saveFileFingerprint(_ fingerprint: CredentialsFileFingerprint?) { + #if DEBUG + if let store = self.taskCredentialsFileFingerprintStoreOverride { store.save(fingerprint); return } + #endif guard let fingerprint else { UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) return @@ -1475,6 +1475,7 @@ public enum ClaudeOAuthCredentialsStore { #if DEBUG static func _resetCredentialsFileTrackingForTesting() { + if let store = self.taskCredentialsFileFingerprintStoreOverride { store.save(nil); return } UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) } @@ -1509,15 +1510,35 @@ extension ClaudeOAuthCredentialsStore { @discardableResult static func syncFromClaudeKeychainWithoutPrompt(now: Date = Date()) -> Bool { #if os(macOS) - if !self.keychainAccessAllowed { return false } + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } - // If background keychain access has been denied/blocked, don't attempt silent reads that could trigger - // repeated prompts on misbehaving configurations. User actions clear/bypass this gate elsewhere. + if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: ProviderInteractionContext.current), + !data.isEmpty + { + if let creds = try? ClaudeOAuthCredentials.parse(data: data), !creds.isExpired { + // Keep delegated refresh recovery on the security CLI path only in experimental mode. + // Avoid Security.framework fingerprint probes here because "no UI" queries can still prompt. + self.writeMemoryCache( + record: ClaudeOAuthCredentialRecord(credentials: creds, owner: .claudeCLI, source: .memoryCache), + timestamp: now) + self.saveToCacheKeychain(data, owner: .claudeCLI) + return true + } + } + + let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { return false } + + // If background keychain access has been denied/blocked, don't attempt silent Security.framework fallback + // reads that could trigger repeated prompts on misbehaving configurations. if ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) { return false } #if DEBUG - // Test hook: allow unit tests to simulate a "silent" keychain read without touching the real Keychain. + // Test hook: allow unit tests to simulate a silent Security.framework fallback read without touching + // the real Keychain. let override = self.taskClaudeKeychainOverrideStore?.data ?? self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride if let override, @@ -1536,10 +1557,12 @@ extension ClaudeOAuthCredentialsStore { // Skip the silent data read if preflight indicates interaction is likely. // Why: on some systems, Security.framework can still surface UI even for "no UI" probes. - if self.shouldShowClaudeKeychainPreAlert() { return false } + if self.shouldShowClaudeKeychainPreAlert() { + return false + } // Consult only the newest candidate to avoid syncing from a different keychain entry (e.g. old login). - if let candidate = self.claudeKeychainCandidatesWithoutPrompt().first, + if let candidate = self.claudeKeychainCandidatesWithoutPrompt(promptMode: fallbackPromptMode).first, let data = try? self.loadClaudeKeychainData(candidate: candidate, allowKeychainPrompt: false), !data.isEmpty { @@ -1561,16 +1584,19 @@ extension ClaudeOAuthCredentialsStore { self.saveClaudeKeychainFingerprint(fingerprint) } - if let data = try? self.loadClaudeKeychainLegacyData(allowKeychainPrompt: false), - !data.isEmpty, - let creds = try? ClaudeOAuthCredentials.parse(data: data), + let legacyData = try? self.loadClaudeKeychainLegacyData( + allowKeychainPrompt: false, + promptMode: fallbackPromptMode) + if let legacyData, + !legacyData.isEmpty, + let creds = try? ClaudeOAuthCredentials.parse(data: legacyData), !creds.isExpired { self.saveClaudeKeychainFingerprint(self.currentClaudeKeychainFingerprintWithoutPrompt()) self.writeMemoryCache( record: ClaudeOAuthCredentialRecord(credentials: creds, owner: .claudeCLI, source: .memoryCache), timestamp: now) - self.saveToCacheKeychain(data, owner: .claudeCLI) + self.saveToCacheKeychain(legacyData, owner: .claudeCLI) return true } @@ -1582,7 +1608,9 @@ extension ClaudeOAuthCredentialsStore { } private static func shouldShowClaudeKeychainPreAlert() -> Bool { - switch KeychainAccessPreflight.checkGenericPassword(service: self.claudeKeychainService, account: nil) { + let mode = ClaudeOAuthKeychainPromptPreference.current() + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } + return switch KeychainAccessPreflight.checkGenericPassword(service: self.claudeKeychainService, account: nil) { case .interactionRequired: true case .failure: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index f589b8dde..d94940602 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -53,13 +53,26 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { self.nextAttemptID += 1 let attemptID = self.nextAttemptID // Detached to avoid inheriting the caller's executor context (e.g. MainActor) and cancellation state. - let task = Task.detached(priority: .utility) { await self.performAttempt(now: now, timeout: timeout) } + let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current() + let keychainAccessDisabled = KeychainAccessGate.isDisabled + let task = Task.detached(priority: .utility) { + await self.performAttempt( + now: now, + timeout: timeout, + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled) + } self.inFlightAttemptID = attemptID self.inFlightTask = task return .start(attemptID, task) } - private static func performAttempt(now: Date, timeout: TimeInterval) async -> Outcome { + private static func performAttempt( + now: Date, + timeout: TimeInterval, + readStrategy: ClaudeOAuthKeychainReadStrategy, + keychainAccessDisabled: Bool) async -> Outcome + { guard self.isClaudeCLIAvailable() else { self.log.info("Claude OAuth delegated refresh skipped: claude CLI unavailable") return .cliUnavailable @@ -72,7 +85,9 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { return .skippedByCooldown } - let fingerprintBefore = self.currentClaudeKeychainFingerprint() + let baseline = self.currentKeychainChangeObservationBaseline( + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled) var touchError: Error? do { @@ -84,7 +99,9 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { // "Touch succeeded" must mean we actually observed the Claude keychain entry change. // Otherwise we end up in a long cooldown with still-expired credentials. let changed = await self.waitForClaudeKeychainChange( - from: fingerprintBefore, + from: baseline, + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled, timeout: min(max(timeout, 1), 2)) if changed { self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval) @@ -145,8 +162,27 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { try await ClaudeStatusProbe.touchOAuthAuthPath(timeout: timeout) } + private enum KeychainChangeObservationBaseline: Sendable { + case securityFramework(fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) + case securityCLI(data: Data?) + } + + private static func currentKeychainChangeObservationBaseline( + readStrategy: ClaudeOAuthKeychainReadStrategy, + keychainAccessDisabled: Bool) -> KeychainChangeObservationBaseline + { + if readStrategy == .securityCLIExperimental { + return .securityCLI(data: self.currentClaudeKeychainDataViaSecurityCLIForObservation( + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled)) + } + return .securityFramework(fingerprint: self.currentClaudeKeychainFingerprint()) + } + private static func waitForClaudeKeychainChange( - from fingerprintBefore: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?, + from baseline: KeychainChangeObservationBaseline, + readStrategy: ClaudeOAuthKeychainReadStrategy, + keychainAccessDisabled: Bool, timeout: TimeInterval) async -> Bool { // Prefer correctness but bound the delay. Keychain writes can be slightly delayed after the CLI touch. @@ -157,13 +193,28 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { let delays: [TimeInterval] = [0.2, 0.5, 0.8].filter { $0 <= clampedTimeout } let deadline = Date().addingTimeInterval(clampedTimeout) - func isObservedChange(_ current: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) -> Bool { - // Treat "no fingerprint" as "not observed"; we only succeed if we can read a fingerprint and it differs. - guard let current else { return false } - return current != fingerprintBefore + func isObservedChange() -> Bool { + switch baseline { + case let .securityFramework(fingerprintBefore): + // Treat "no fingerprint" as "not observed"; we only succeed if we can read a fingerprint and it + // differs. + guard let current = self.currentClaudeKeychainFingerprintForObservation() else { return false } + return current != fingerprintBefore + case let .securityCLI(dataBefore): + // In experimental mode, avoid Security.framework observation entirely and detect change from + // /usr/bin/security output only. + // If baseline capture failed (nil), treat observation as inconclusive and do not infer a change from + // a later successful read. + guard let dataBefore else { return false } + guard let current = self.currentClaudeKeychainDataViaSecurityCLIForObservation( + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled) + else { return false } + return current != dataBefore + } } - if isObservedChange(self.currentClaudeKeychainFingerprintForObservation()) { + if isObservedChange() { return true } @@ -176,7 +227,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { return false } - if isObservedChange(self.currentClaudeKeychainFingerprintForObservation()) { + if isObservedChange() { return true } } @@ -212,6 +263,16 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } } + private static func currentClaudeKeychainDataViaSecurityCLIForObservation( + readStrategy: ClaudeOAuthKeychainReadStrategy, + keychainAccessDisabled: Bool) -> Data? + { + guard !keychainAccessDisabled else { return nil } + return ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + interaction: .background, + readStrategy: readStrategy) + } + private static func clearInFlightTaskIfStillCurrent(id: UInt64) { self.stateLock.lock() if self.inFlightAttemptID == id { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift index 743246a54..3ae21771b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift @@ -14,6 +14,10 @@ public enum ClaudeOAuthKeychainPromptPreference { #endif public static func current(userDefaults: UserDefaults = .standard) -> ClaudeOAuthKeychainPromptMode { + self.effectiveMode(userDefaults: userDefaults) + } + + public static func storedMode(userDefaults: UserDefaults = .standard) -> ClaudeOAuthKeychainPromptMode { #if DEBUG if let taskOverride { return taskOverride } #endif @@ -25,6 +29,34 @@ public enum ClaudeOAuthKeychainPromptPreference { return .onlyOnUserAction } + public static func isApplicable( + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) -> Bool + { + readStrategy == .securityFramework + } + + public static func effectiveMode( + userDefaults: UserDefaults = .standard, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + -> ClaudeOAuthKeychainPromptMode + { + guard self.isApplicable(readStrategy: readStrategy) else { + return .always + } + return self.storedMode(userDefaults: userDefaults) + } + + public static func securityFrameworkFallbackMode( + userDefaults: UserDefaults = .standard, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + -> ClaudeOAuthKeychainPromptMode + { + if readStrategy == .securityCLIExperimental { + return self.storedMode(userDefaults: userDefaults) + } + return self.effectiveMode(userDefaults: userDefaults, readStrategy: readStrategy) + } + #if DEBUG static func withTaskOverrideForTesting( _ mode: ClaudeOAuthKeychainPromptMode?, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift new file mode 100644 index 000000000..7f766d1e4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum ClaudeOAuthKeychainReadStrategy: String, Sendable, Codable, CaseIterable { + case securityFramework + case securityCLIExperimental +} + +public enum ClaudeOAuthKeychainReadStrategyPreference { + private static let userDefaultsKey = "claudeOAuthKeychainReadStrategy" + + #if DEBUG + @TaskLocal private static var taskOverride: ClaudeOAuthKeychainReadStrategy? + #endif + + public static func current(userDefaults: UserDefaults = .standard) -> ClaudeOAuthKeychainReadStrategy { + #if DEBUG + if let taskOverride { return taskOverride } + #endif + if let raw = userDefaults.string(forKey: self.userDefaultsKey), + let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) + { + return strategy + } + return .securityFramework + } + + #if DEBUG + static func withTaskOverrideForTesting( + _ strategy: ClaudeOAuthKeychainReadStrategy?, + operation: () throws -> T) rethrows -> T + { + try self.$taskOverride.withValue(strategy) { + try operation() + } + } + + static func withTaskOverrideForTesting( + _ strategy: ClaudeOAuthKeychainReadStrategy?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskOverride.withValue(strategy) { + try await operation() + } + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 4cfd639fd..667fbaff3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -33,7 +33,7 @@ public enum ClaudeProviderDescriptor { supportsTokenCost: true, noDataMessage: self.noDataMessage), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto], + sourceModes: [.auto, .web, .cli, .oauth], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "claude", @@ -43,11 +43,55 @@ public enum ClaudeProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - switch context.sourceMode { - case .api: - [] - case .auto, .oauth, .web, .cli: - [ClaudeKeychainCLIFetchStrategy()] + switch context.runtime { + case .cli: + switch context.sourceMode { + case .oauth: + return [ClaudeOAuthFetchStrategy()] + case .web: + return [ClaudeWebFetchStrategy(browserDetection: context.browserDetection)] + case .cli: + return [ClaudeCLIFetchStrategy( + useWebExtras: false, + manualCookieHeader: nil, + browserDetection: context.browserDetection)] + case .api: + return [] + case .auto: + return [ + ClaudeOAuthFetchStrategy(), + ClaudeWebFetchStrategy(browserDetection: context.browserDetection), + ClaudeCLIFetchStrategy( + useWebExtras: false, + manualCookieHeader: nil, + browserDetection: context.browserDetection), + ] + } + case .app: + let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false + let manualCookieHeader = CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) + switch context.sourceMode { + case .oauth: + return [ClaudeOAuthFetchStrategy()] + case .web: + return [ClaudeWebFetchStrategy(browserDetection: context.browserDetection)] + case .cli: + return [ClaudeCLIFetchStrategy( + useWebExtras: webExtrasEnabled, + manualCookieHeader: manualCookieHeader, + browserDetection: context.browserDetection)] + case .api: + return [] + case .auto: + return [ + ClaudeOAuthFetchStrategy(), + ClaudeCLIFetchStrategy( + useWebExtras: webExtrasEnabled, + manualCookieHeader: manualCookieHeader, + browserDetection: context.browserDetection), + ClaudeWebFetchStrategy(browserDetection: context.browserDetection), + ] + } } } @@ -59,12 +103,16 @@ public enum ClaudeProviderDescriptor { selectedDataSource: ClaudeUsageDataSource, webExtrasEnabled: Bool, hasWebSession: Bool, + hasCLI: Bool, hasOAuthCredentials: Bool) -> ClaudeUsageStrategy { if selectedDataSource == .auto { if hasOAuthCredentials { return ClaudeUsageStrategy(dataSource: .oauth, useWebExtras: false) } + if hasCLI { + return ClaudeUsageStrategy(dataSource: .cli, useWebExtras: false) + } if hasWebSession { return ClaudeUsageStrategy(dataSource: .web, useWebExtras: false) } @@ -81,148 +129,217 @@ public struct ClaudeUsageStrategy: Equatable, Sendable { public let useWebExtras: Bool } -/// Fetches Claude usage by reading OAuth credentials from the macOS Keychain via the `/usr/bin/security` CLI. -/// This avoids Keychain permission prompts because the `security` binary is already in the keychain ACL, -/// unlike direct `SecItemCopyMatching` calls which trigger macOS permission dialogs. -struct ClaudeKeychainCLIFetchStrategy: ProviderFetchStrategy { - let id: String = "claude.keychain-cli" +struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { + let id: String = "claude.oauth" let kind: ProviderFetchKind = .oauth - func isAvailable(_: ProviderFetchContext) async -> Bool { - true - } + #if DEBUG + @TaskLocal static var nonInteractiveCredentialRecordOverride: ClaudeOAuthCredentialRecord? + @TaskLocal static var claudeCLIAvailableOverride: Bool? + #endif - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - let credentials = try Self.loadCredentialsViaCLI() - let usage = try await ClaudeOAuthUsageFetcher.fetchUsage(accessToken: credentials.accessToken) - let snapshot = try Self.mapUsage(usage, credentials: credentials) - return self.makeResult( - usage: snapshot, - sourceLabel: "keychain-cli") - } + private func loadNonInteractiveCredentialRecord(_ context: ProviderFetchContext) -> ClaudeOAuthCredentialRecord? { + #if DEBUG + if let override = Self.nonInteractiveCredentialRecordOverride { return override } + #endif - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + return try? ClaudeOAuthCredentialsStore.loadRecord( + environment: context.env, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) } - // MARK: - Keychain CLI - - private static func loadCredentialsViaCLI() throws -> ClaudeOAuthCredentials { - if let creds = try? self.runSecurityCLI(service: "Claude Code-credentials") { - return creds - } - return try self.runSecurityCLI(service: "Claude Code") + private func isClaudeCLIAvailable() -> Bool { + #if DEBUG + if let override = Self.claudeCLIAvailableOverride { return override } + #endif + return ClaudeOAuthDelegatedRefreshCoordinator.isClaudeCLIAvailable() } - private static func runSecurityCLI(service: String) throws -> ClaudeOAuthCredentials { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/security") - process.arguments = ["find-generic-password", "-s", service, "-w"] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let nonInteractiveRecord = self.loadNonInteractiveCredentialRecord(context) + let nonInteractiveCredentials = nonInteractiveRecord?.credentials + let hasRequiredScopeWithoutPrompt = nonInteractiveCredentials?.scopes.contains("user:profile") == true + if hasRequiredScopeWithoutPrompt, nonInteractiveCredentials?.isExpired == false { + // Gate controls refresh attempts, not use of already-valid access tokens. + return true + } - try process.run() - process.waitUntilExit() + let hasEnvironmentOAuthToken = !(context.env[ClaudeOAuthCredentialsStore.environmentTokenKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true) + let claudeCLIAvailable = self.isClaudeCLIAvailable() - guard process.terminationStatus == 0 else { - let errorData = stderr.fileHandleForReading.readDataToEndOfFile() - let errorString = String(data: errorData, encoding: .utf8) ?? "" - throw ClaudeUsageError.oauthFailed( - "Keychain CLI failed for service \"\(service)\": " - + errorString.trimmingCharacters(in: .whitespacesAndNewlines)) + if hasEnvironmentOAuthToken { + return true } - let data = stdout.fileHandleForReading.readDataToEndOfFile() - guard let jsonString = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !jsonString.isEmpty, - let jsonData = jsonString.data(using: .utf8) - else { - throw ClaudeUsageError.oauthFailed("Empty keychain response for service \"\(service)\"") + if let nonInteractiveRecord, hasRequiredScopeWithoutPrompt, nonInteractiveRecord.credentials.isExpired { + switch nonInteractiveRecord.owner { + case .codexbar: + let refreshToken = nonInteractiveRecord.credentials.refreshToken? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if context.sourceMode == .auto { + return !refreshToken.isEmpty + } + return true + case .claudeCLI: + if context.sourceMode == .auto { + return claudeCLIAvailable + } + return true + case .environment: + return context.sourceMode != .auto + } } - return try ClaudeOAuthCredentials.parse(data: jsonData) - } + guard context.sourceMode == .auto else { return true } - // MARK: - Usage mapping + // Prefer OAuth in Auto mode only when it’s plausibly usable: + // - we can load credentials without prompting (env / CodexBar cache / credentials file) AND they meet the + // scope requirement, or + // - Claude Code has stored OAuth creds in Keychain and we may be able to bootstrap (one prompt max). + // + // User actions should be able to recover immediately even if a prior background attempt tripped the + // keychain cooldown gate. Clear the cooldown before deciding availability so the fetch path can proceed. + let promptPolicyApplicable = ClaudeOAuthKeychainPromptPreference.isApplicable() + if promptPolicyApplicable, ProviderInteractionContext.current == .userInitiated { + _ = ClaudeOAuthKeychainAccessGate.clearDenied() + } - private static func mapUsage( - _ usage: OAuthUsageResponse, - credentials: ClaudeOAuthCredentials) throws -> UsageSnapshot - { - func makeWindow(_ window: OAuthUsageWindow?, windowMinutes: Int?) -> RateWindow? { - guard let window, let utilization = window.utilization else { return nil } - let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) - let resetDescription = resetDate.map { UsageFormatter.resetDescription(from: $0) } - return RateWindow( - usedPercent: utilization, - windowMinutes: windowMinutes, - resetsAt: resetDate, - resetDescription: resetDescription) + let shouldAllowStartupBootstrap = promptPolicyApplicable && + context.runtime == .app && + ProviderRefreshContext.current == .startup && + ProviderInteractionContext.current == .background && + ClaudeOAuthKeychainPromptPreference.current() == .onlyOnUserAction && + !ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: context.env) + if shouldAllowStartupBootstrap { + return ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) else { - throw ClaudeUsageError.parseFailed("missing session data") + if promptPolicyApplicable, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() + { + return false } + return ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() + } - let weekly = makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) - let modelSpecific = makeWindow( - usage.sevenDaySonnet ?? usage.sevenDayOpus, - windowMinutes: 7 * 24 * 60) + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = ClaudeUsageFetcher( + browserDetection: context.browserDetection, + environment: context.env, + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: context.sourceMode == .auto, + allowBackgroundDelegatedRefresh: context.runtime == .cli, + allowStartupBootstrapPrompt: context.runtime == .app && + (context.sourceMode == .auto || context.sourceMode == .oauth), + useWebExtras: false) + let usage = try await fetcher.loadLatestUsage(model: "sonnet") + return self.makeResult( + usage: Self.snapshot(from: usage), + sourceLabel: "oauth") + } - let loginMethod = Self.inferPlan(rateLimitTier: credentials.rateLimitTier) - let providerCost = Self.mapExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + // In Auto mode, fall back to the next strategy (cli/web) if OAuth fails (e.g. user cancels keychain prompt + // or auth breaks). + context.runtime == .app && context.sourceMode == .auto + } + fileprivate static func snapshot(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { let identity = ProviderIdentitySnapshot( providerID: .claude, - accountEmail: nil, - accountOrganization: nil, - loginMethod: loginMethod) - + accountEmail: usage.accountEmail, + accountOrganization: usage.accountOrganization, + loginMethod: usage.loginMethod) return UsageSnapshot( - primary: primary, - secondary: weekly, - tertiary: modelSpecific, - providerCost: providerCost, - updatedAt: Date(), + primary: usage.primary, + secondary: usage.secondary, + tertiary: usage.opus, + providerCost: usage.providerCost, + updatedAt: usage.updatedAt, identity: identity) } +} + +struct ClaudeWebFetchStrategy: ProviderFetchStrategy { + let id: String = "claude.web" + let kind: ProviderFetchKind = .web + let browserDetection: BrowserDetection + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.isAvailableForFallback(context: context, browserDetection: self.browserDetection) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = ClaudeUsageFetcher( + browserDetection: browserDetection, + dataSource: .web, + useWebExtras: false, + manualCookieHeader: Self.manualCookieHeader(from: context)) + let usage = try await fetcher.loadLatestUsage(model: "sonnet") + return self.makeResult( + usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), + sourceLabel: "web") + } - private static func inferPlan(rateLimitTier: String?) -> String? { - let tier = rateLimitTier?.lowercased() ?? "" - if tier.contains("max") { return "Claude Max" } - if tier.contains("pro") { return "Claude Pro" } - if tier.contains("team") { return "Claude Team" } - if tier.contains("enterprise") { return "Claude Enterprise" } - return nil + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + _ = error + // In CLI runtime auto mode, web comes before CLI so fallback is required. + // In app runtime auto mode, web is terminal and should surface its concrete error. + return context.runtime == .cli } - private static func mapExtraUsageCost( - _ extra: OAuthExtraUsage?, - loginMethod: String?) -> ProviderCostSnapshot? + fileprivate static func isAvailableForFallback( + context: ProviderFetchContext, + browserDetection: BrowserDetection) -> Bool { - guard let extra, extra.isEnabled == true else { return nil } - guard let used = extra.usedCredits, let limit = extra.monthlyLimit else { return nil } - let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines) - let code = (currency?.isEmpty ?? true) ? "USD" : currency! - // Claude's OAuth API returns values in cents; convert to dollars. - var costUsed = used / 100.0 - var costLimit = limit / 100.0 - // Non-enterprise plans may report amounts 100x too high; rescale if limit looks implausible. - let normalized = loginMethod?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - if !normalized.contains("enterprise"), costLimit >= 1000 { - costUsed /= 100.0 - costLimit /= 100.0 + if let header = self.manualCookieHeader(from: context) { + return ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) } - return ProviderCostSnapshot( - used: costUsed, - limit: costLimit, - currencyCode: code, - period: "Monthly", - resetsAt: nil, - updatedAt: Date()) + guard context.settings?.claude?.cookieSource != .off else { return false } + return ClaudeWebAPIFetcher.hasSessionKey(browserDetection: browserDetection) + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.claude?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) + } +} + +struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { + let id: String = "claude.cli" + let kind: ProviderFetchKind = .cli + let useWebExtras: Bool + let manualCookieHeader: String? + let browserDetection: BrowserDetection + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let keepAlive = context.settings?.debugKeepCLISessionsAlive ?? false + let fetcher = ClaudeUsageFetcher( + browserDetection: browserDetection, + dataSource: .cli, + useWebExtras: self.useWebExtras, + manualCookieHeader: self.manualCookieHeader, + keepCLISessionsAlive: keepAlive) + let usage = try await fetcher.loadLatestUsage(model: "sonnet") + return self.makeResult( + usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), + sourceLabel: "claude") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + guard context.runtime == .app, context.sourceMode == .auto else { return false } + // Only fall through when web is actually available; otherwise preserve actionable CLI errors. + return ClaudeWebFetchStrategy.isAvailableForFallback( + context: context, + browserDetection: self.browserDetection) } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 9b128544f..ed57ac71b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -61,6 +61,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private let environment: [String: String] private let dataSource: ClaudeUsageDataSource private let oauthKeychainPromptCooldownEnabled: Bool + private let allowBackgroundDelegatedRefresh: Bool + private let allowStartupBootstrapPrompt: Bool private let useWebExtras: Bool private let manualCookieHeader: String? private let keepCLISessionsAlive: Bool @@ -72,6 +74,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private struct ClaudeOAuthKeychainPromptPolicy: Sendable { let mode: ClaudeOAuthKeychainPromptMode + let isApplicable: Bool let interaction: ProviderInteraction var canPromptNow: Bool { @@ -97,12 +100,14 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private static func currentClaudeOAuthKeychainPromptPolicy() -> ClaudeOAuthKeychainPromptPolicy { + let isApplicable = ClaudeOAuthKeychainPromptPreference.isApplicable() let policy = ClaudeOAuthKeychainPromptPolicy( mode: ClaudeOAuthKeychainPromptPreference.current(), + isApplicable: isApplicable, interaction: ProviderInteractionContext.current) // User actions should be able to immediately retry a repair after a background cooldown was recorded. - if policy.interaction == .userInitiated { + if policy.isApplicable, policy.interaction == .userInitiated { if ClaudeOAuthKeychainAccessGate.clearDenied() { Self.log.info("Claude OAuth keychain cooldown cleared by user action") } @@ -110,6 +115,24 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { return policy } + private static func assertDelegatedRefreshAllowedInCurrentInteraction( + policy: ClaudeOAuthKeychainPromptPolicy, + allowBackgroundDelegatedRefresh: Bool) throws + { + guard policy.isApplicable else { return } + if policy.mode == .never { + throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.") + } + if policy.mode == .onlyOnUserAction, + policy.interaction != .userInitiated, + !allowBackgroundDelegatedRefresh + { + throw ClaudeUsageError.oauthFailed( + "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy " + + "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.") + } + } + #if DEBUG @TaskLocal static var loadOAuthCredentialsOverride: (@Sendable ( [String: String], @@ -119,6 +142,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { @TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable ( Date, TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? + @TaskLocal static var hasCachedCredentialsOverride: Bool? #endif /// Creates a new ClaudeUsageFetcher. @@ -131,6 +155,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { environment: [String: String] = ProcessInfo.processInfo.environment, dataSource: ClaudeUsageDataSource = .oauth, oauthKeychainPromptCooldownEnabled: Bool = false, + allowBackgroundDelegatedRefresh: Bool = false, + allowStartupBootstrapPrompt: Bool = false, useWebExtras: Bool = false, manualCookieHeader: String? = nil, keepCLISessionsAlive: Bool = false) @@ -139,6 +165,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { self.environment = environment self.dataSource = dataSource self.oauthKeychainPromptCooldownEnabled = oauthKeychainPromptCooldownEnabled + self.allowBackgroundDelegatedRefresh = allowBackgroundDelegatedRefresh + self.allowStartupBootstrapPrompt = allowStartupBootstrapPrompt self.useWebExtras = useWebExtras self.manualCookieHeader = manualCookieHeader self.keepCLISessionsAlive = keepCLISessionsAlive @@ -279,88 +307,89 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } } - public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { - switch self.dataSource { - case .auto: - let oauthCreds = try? ClaudeOAuthCredentialsStore.load( - environment: self.environment, - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false - let hasWebSession = - if let header = self.manualCookieHeader { - ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) - } else { - ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) - } - let hasCLI = TTYCommandRunner.which("claude") != nil - if hasOAuthCredentials { - var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - } - if hasWebSession { - return try await self.loadViaWebAPI() - } - if hasCLI { - do { - var snap = try await self.loadViaPTY(model: model, timeout: 10) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - } catch { - // CLI failed; OAuth is the last resort. - } - } - var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - case .oauth: - var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - case .web: - return try await self.loadViaWebAPI() - case .cli: - do { - var snap = try await self.loadViaPTY(model: model, timeout: 10) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - } catch { - var snap = try await self.loadViaPTY(model: model, timeout: 24) - snap = await self.applyWebExtrasIfNeeded(to: snap) - return snap - } - } + // MARK: - OAuth API path + + private func shouldAllowStartupBootstrapPrompt( + policy: ClaudeOAuthKeychainPromptPolicy, + hasCache: Bool) -> Bool + { + guard policy.isApplicable else { return false } + guard self.allowStartupBootstrapPrompt else { return false } + guard !hasCache else { return false } + guard policy.mode == .onlyOnUserAction else { return false } + guard policy.interaction == .background else { return false } + return ProviderRefreshContext.current == .startup } - // MARK: - OAuth API path + private static func logOAuthBootstrapPromptDecision( + allowKeychainPrompt: Bool, + policy: ClaudeOAuthKeychainPromptPolicy, + hasCache: Bool, + startupBootstrapOverride: Bool) + { + guard allowKeychainPrompt else { return } + self.log.info( + "Claude OAuth keychain prompt allowed (bootstrap)", + metadata: [ + "interaction": policy.interactionLabel, + "promptMode": policy.mode.rawValue, + "promptPolicyApplicable": "\(policy.isApplicable)", + "hasCache": "\(hasCache)", + "startupBootstrapOverride": "\(startupBootstrapOverride)", + ]) + } + + private static func logDeferredBackgroundDelegatedRecoveryIfNeeded( + delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, + didSyncSilently: Bool, + policy: ClaudeOAuthKeychainPromptPolicy) + { + guard delegatedOutcome == .attemptedSucceeded else { return } + guard !didSyncSilently else { return } + guard policy.mode == .onlyOnUserAction else { return } + guard policy.interaction == .background else { return } + self.log.info( + "Claude OAuth delegated refresh completed; background recovery deferred until user action", + metadata: [ + "interaction": policy.interactionLabel, + "promptMode": policy.mode.rawValue, + "delegatedOutcome": self.delegatedRefreshOutcomeLabel(delegatedOutcome), + ]) + } private func loadViaOAuth(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { do { let promptPolicy = Self.currentClaudeOAuthKeychainPromptPolicy() // Allow keychain prompt when no cached credentials exist (bootstrap case) + #if DEBUG + let hasCache = Self.hasCachedCredentialsOverride + ?? ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.environment) + #else let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.environment) + #endif + let startupBootstrapOverride = self.shouldAllowStartupBootstrapPrompt( + policy: promptPolicy, + hasCache: hasCache) // Note: `hasCachedCredentials` intentionally returns true for expired Claude-CLI-owned creds, because the // repair path is delegated refresh via Claude CLI (followed by a silent re-sync) rather than immediately // prompting on the initial load. - let allowKeychainPrompt = promptPolicy.canPromptNow && !hasCache - if allowKeychainPrompt { - Self.log.info( - "Claude OAuth keychain prompt allowed (bootstrap)", - metadata: [ - "interaction": promptPolicy.interactionLabel, - "promptMode": promptPolicy.mode.rawValue, - "hasCache": "\(hasCache)", - ]) - } + let allowKeychainPrompt = (promptPolicy.canPromptNow || startupBootstrapOverride) && !hasCache + Self.logOAuthBootstrapPromptDecision( + allowKeychainPrompt: allowKeychainPrompt, + policy: promptPolicy, + hasCache: hasCache, + startupBootstrapOverride: startupBootstrapOverride) // Ownership-aware credential loading: // - Claude CLI-owned credentials delegate refresh to Claude CLI. // - CodexBar-owned credentials use direct token-endpoint refresh. - let creds = try await Self.loadOAuthCredentials( - environment: self.environment, - allowKeychainPrompt: allowKeychainPrompt, - respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) + let creds = try await ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap + .withValue(startupBootstrapOverride) { + try await Self.loadOAuthCredentials( + environment: self.environment, + allowKeychainPrompt: allowKeychainPrompt, + respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) + } // The usage endpoint requires user:profile scope. if !creds.scopes.contains("user:profile") { throw ClaudeUsageError.oauthFailed( @@ -375,112 +404,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { throw error } catch let error as ClaudeOAuthCredentialsError { if case .refreshDelegatedToClaudeCLI = error { - guard allowDelegatedRetry else { - throw ClaudeUsageError.oauthFailed( - "Claude OAuth token expired and delegated Claude CLI refresh did not recover. " - + "Run `claude login`, then retry.") - } - - try Task.checkCancellation() - - let delegatedOutcome = await Self.attemptDelegatedRefresh() - Self.log.info( - "Claude OAuth delegated refresh attempted", - metadata: [ - "outcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), - ]) - - do { - // In Auto mode, avoid forcing interactive Keychain prompts or blocking the fallback chain when - // delegation cannot run. - if self.oauthKeychainPromptCooldownEnabled { - switch delegatedOutcome { - case .skippedByCooldown, .cliUnavailable: - throw ClaudeUsageError.oauthFailed( - "Claude OAuth token expired; delegated refresh is unavailable (outcome=" - + "\(Self.delegatedRefreshOutcomeLabel(delegatedOutcome))).") - case .attemptedSucceeded: - break - case .attemptedFailed: - // Delegation ran but didn't observe a keychain change. We'll attempt a non-interactive - // reload below (allowKeychainPrompt=false) and then allow the Auto chain to fall back. - break - } - } - - try Task.checkCancellation() - - // After delegated refresh, reload credentials and retry OAuth once. - // In OAuth mode we allow an interactive Keychain prompt here; in Auto mode we keep it silent to - // avoid bypassing the prompt cooldown and to let the fallback chain proceed. - _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() - - let didSyncSilently: Bool = { - guard delegatedOutcome == .attemptedSucceeded else { return false } - return ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) - }() - - let promptPolicy = Self.currentClaudeOAuthKeychainPromptPolicy() - let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently - if retryAllowKeychainPrompt { - Self.log.info( - "Claude OAuth keychain prompt allowed (post-delegation retry)", - metadata: [ - "interaction": promptPolicy.interactionLabel, - "promptMode": promptPolicy.mode.rawValue, - "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), - "didSyncSilently": "\(didSyncSilently)", - ]) - } - if Self.isClaudeOAuthFlowDebugEnabled { - Self.log.debug( - "Claude OAuth credential load (post-delegation retry start)", - metadata: [ - "cooldownEnabled": "\(self.oauthKeychainPromptCooldownEnabled)", - "didSyncSilently": "\(didSyncSilently)", - "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", - "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), - "interaction": promptPolicy.interactionLabel, - "promptMode": promptPolicy.mode.rawValue, - ]) - } - let refreshedCreds = try await Self.loadOAuthCredentials( - environment: self.environment, - allowKeychainPrompt: retryAllowKeychainPrompt, - respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) - if Self.isClaudeOAuthFlowDebugEnabled { - Self.log.debug( - "Claude OAuth credential load (post-delegation retry)", - metadata: [ - "cooldownEnabled": "\(self.oauthKeychainPromptCooldownEnabled)", - "didSyncSilently": "\(didSyncSilently)", - "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", - "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), - "interaction": promptPolicy.interactionLabel, - "promptMode": promptPolicy.mode.rawValue, - ]) - } - - if !refreshedCreds.scopes.contains("user:profile") { - let scopes = refreshedCreds.scopes.joined(separator: ", ") - throw ClaudeUsageError.oauthFailed( - "Claude OAuth token missing 'user:profile' scope (has: \(scopes)). " - + "Run `claude setup-token` to re-generate credentials, " - + "or switch Claude Source to Web/CLI.") - } - - let usage = try await Self.fetchOAuthUsage(accessToken: refreshedCreds.accessToken) - return try Self.mapOAuthUsage(usage, credentials: refreshedCreds) - } catch { - Self.log.debug( - "Claude OAuth post-delegation retry failed", - metadata: Self.delegatedRetryFailureMetadata( - error: error, - oauthKeychainPromptCooldownEnabled: self.oauthKeychainPromptCooldownEnabled, - delegatedOutcome: delegatedOutcome)) - throw ClaudeUsageError.oauthFailed( - Self.delegatedRefreshFailureMessage(for: delegatedOutcome, retryError: error)) - } + return try await self.loadViaOAuthAfterDelegatedRefresh(allowDelegatedRetry: allowDelegatedRetry) } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { @@ -499,6 +423,125 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } } + private func loadViaOAuthAfterDelegatedRefresh(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot { + guard allowDelegatedRetry else { + throw ClaudeUsageError.oauthFailed( + "Claude OAuth token expired and delegated Claude CLI refresh did not recover. " + + "Run `claude login`, then retry.") + } + + try Task.checkCancellation() + + let delegatedPromptPolicy = Self.currentClaudeOAuthKeychainPromptPolicy() + try Self.assertDelegatedRefreshAllowedInCurrentInteraction( + policy: delegatedPromptPolicy, + allowBackgroundDelegatedRefresh: self.allowBackgroundDelegatedRefresh) + + let delegatedOutcome = await Self.attemptDelegatedRefresh() + Self.log.info( + "Claude OAuth delegated refresh attempted", + metadata: [ + "outcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), + ]) + + do { + // In Auto mode, avoid forcing interactive Keychain prompts or blocking the fallback chain when + // delegation cannot run. + if self.oauthKeychainPromptCooldownEnabled { + switch delegatedOutcome { + case .skippedByCooldown, .cliUnavailable: + throw ClaudeUsageError.oauthFailed( + "Claude OAuth token expired; delegated refresh is unavailable (outcome=" + + "\(Self.delegatedRefreshOutcomeLabel(delegatedOutcome))).") + case .attemptedSucceeded: + break + case .attemptedFailed: + // Delegation ran but didn't observe a keychain change. We'll attempt a non-interactive reload + // below (allowKeychainPrompt=false) and then allow the Auto chain to fall back. + break + } + } + + try Task.checkCancellation() + + // After delegated refresh, reload credentials and retry OAuth once. + // In OAuth mode we allow an interactive Keychain prompt here; in Auto mode we keep it silent to avoid + // bypassing the prompt cooldown and to let the fallback chain proceed. + _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + + let didSyncSilently = delegatedOutcome == .attemptedSucceeded + && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) + + let promptPolicy = Self.currentClaudeOAuthKeychainPromptPolicy() + Self.logDeferredBackgroundDelegatedRecoveryIfNeeded( + delegatedOutcome: delegatedOutcome, + didSyncSilently: didSyncSilently, + policy: promptPolicy) + let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently + if retryAllowKeychainPrompt { + Self.log.info( + "Claude OAuth keychain prompt allowed (post-delegation retry)", + metadata: [ + "interaction": promptPolicy.interactionLabel, + "promptMode": promptPolicy.mode.rawValue, + "promptPolicyApplicable": "\(promptPolicy.isApplicable)", + "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), + "didSyncSilently": "\(didSyncSilently)", + ]) + } + if Self.isClaudeOAuthFlowDebugEnabled { + Self.log.debug( + "Claude OAuth credential load (post-delegation retry start)", + metadata: [ + "cooldownEnabled": "\(self.oauthKeychainPromptCooldownEnabled)", + "didSyncSilently": "\(didSyncSilently)", + "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", + "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), + "interaction": promptPolicy.interactionLabel, + "promptMode": promptPolicy.mode.rawValue, + "promptPolicyApplicable": "\(promptPolicy.isApplicable)", + ]) + } + let refreshedCreds = try await Self.loadOAuthCredentials( + environment: self.environment, + allowKeychainPrompt: retryAllowKeychainPrompt, + respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown) + if Self.isClaudeOAuthFlowDebugEnabled { + Self.log.debug( + "Claude OAuth credential load (post-delegation retry)", + metadata: [ + "cooldownEnabled": "\(self.oauthKeychainPromptCooldownEnabled)", + "didSyncSilently": "\(didSyncSilently)", + "allowKeychainPrompt": "\(retryAllowKeychainPrompt)", + "delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome), + "interaction": promptPolicy.interactionLabel, + "promptMode": promptPolicy.mode.rawValue, + "promptPolicyApplicable": "\(promptPolicy.isApplicable)", + ]) + } + + if !refreshedCreds.scopes.contains("user:profile") { + let scopes = refreshedCreds.scopes.joined(separator: ", ") + throw ClaudeUsageError.oauthFailed( + "Claude OAuth token missing 'user:profile' scope (has: \(scopes)). " + + "Run `claude setup-token` to re-generate credentials, " + + "or switch Claude Source to Web/CLI.") + } + + let usage = try await Self.fetchOAuthUsage(accessToken: refreshedCreds.accessToken) + return try Self.mapOAuthUsage(usage, credentials: refreshedCreds) + } catch { + Self.log.debug( + "Claude OAuth post-delegation retry failed", + metadata: Self.delegatedRetryFailureMetadata( + error: error, + oauthKeychainPromptCooldownEnabled: self.oauthKeychainPromptCooldownEnabled, + delegatedOutcome: delegatedOutcome)) + throw ClaudeUsageError.oauthFailed( + Self.delegatedRefreshFailureMessage(for: delegatedOutcome, retryError: error)) + } + } + private static func loadOAuthCredentials( environment: [String: String], allowKeychainPrompt: Bool, @@ -892,6 +935,131 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } } +extension ClaudeUsageFetcher { + public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { + switch self.dataSource { + case .auto: + let oauthCreds: ClaudeOAuthCredentials? + let oauthProbeError: Error? + do { + oauthCreds = try ClaudeOAuthCredentialsStore.load( + environment: self.environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + oauthProbeError = nil + } catch { + oauthCreds = nil + oauthProbeError = error + } + + let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false + let hasWebSession = + if let header = self.manualCookieHeader { + ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) + } else { + ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) + } + let hasCLI = TTYCommandRunner.which("claude") != nil + + var autoDecisionMetadata: [String: String] = [ + "hasOAuthCredentials": "\(hasOAuthCredentials)", + "hasWebSession": "\(hasWebSession)", + "hasCLI": "\(hasCLI)", + "oauthReadStrategy": ClaudeOAuthKeychainReadStrategyPreference.current().rawValue, + ] + if let oauthCreds { + autoDecisionMetadata["oauthProbe"] = "success" + for (key, value) in oauthCreds.diagnosticsMetadata(now: Date()) { + autoDecisionMetadata[key] = value + } + } else if let oauthProbeError { + autoDecisionMetadata["oauthProbe"] = "failure" + autoDecisionMetadata["oauthProbeError"] = Self.oauthCredentialProbeErrorLabel(oauthProbeError) + } else { + autoDecisionMetadata["oauthProbe"] = "none" + } + + func logAutoDecision(selected: String) { + var metadata = autoDecisionMetadata + metadata["selected"] = selected + Self.log.debug("Claude auto source decision", metadata: metadata) + } + + if hasOAuthCredentials { + logAutoDecision(selected: "oauth") + var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + } + if hasWebSession { + logAutoDecision(selected: "web") + return try await self.loadViaWebAPI() + } + if hasCLI { + do { + logAutoDecision(selected: "cli") + var snap = try await self.loadViaPTY(model: model, timeout: 10) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + } catch { + Self.log.debug( + "Claude auto source CLI path failed; falling back to OAuth", + metadata: [ + "errorType": String(describing: type(of: error)), + ]) + } + } + logAutoDecision(selected: "oauthFallback") + var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + case .oauth: + var snap = try await self.loadViaOAuth(allowDelegatedRetry: true) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + case .web: + return try await self.loadViaWebAPI() + case .cli: + do { + var snap = try await self.loadViaPTY(model: model, timeout: 10) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + } catch { + var snap = try await self.loadViaPTY(model: model, timeout: 24) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + } + } + } + + private static func oauthCredentialProbeErrorLabel(_ error: Error) -> String { + guard let oauthError = error as? ClaudeOAuthCredentialsError else { + return String(describing: type(of: error)) + } + + return switch oauthError { + case .decodeFailed: + "decodeFailed" + case .missingOAuth: + "missingOAuth" + case .missingAccessToken: + "missingAccessToken" + case .notFound: + "notFound" + case let .keychainError(status): + "keychainError:\(status)" + case .readFailed: + "readFailed" + case .refreshFailed: + "refreshFailed" + case .noRefreshToken: + "noRefreshToken" + case .refreshDelegatedToClaudeCLI: + "refreshDelegatedToClaudeCLI" + } + } +} + #if DEBUG extension ClaudeUsageFetcher { public static func _mapOAuthUsageForTesting( diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index fd408da80..2e90d136a 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -359,14 +359,30 @@ public struct KiroStatusProbe: Sendable { // Track which key patterns matched to detect format changes var matchedPercent = false var matchedCredits = false + var matchedNewFormat = false - // Parse plan name from "| KIRO FREE" or similar + // Parse plan name from "| KIRO FREE" or similar (legacy format) var planName = "Kiro" if let planMatch = stripped.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) { let raw = String(stripped[planMatch]).replacingOccurrences(of: "|", with: "") planName = raw.trimmingCharacters(in: .whitespaces) } + // Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+) + if let newPlanMatch = stripped.range(of: #"Plan:\s*(.+)"#, options: .regularExpression) { + let line = String(stripped[newPlanMatch]) + // Extract just the plan name, stopping at newline + let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces) + if let firstLine = planLine.split(separator: "\n").first { + planName = String(firstLine).trimmingCharacters(in: .whitespaces) + matchedNewFormat = true + } + } + + // Check if this is a managed plan with no usage data + let isManagedPlan = lowered.contains("managed by admin") + || lowered.contains("managed by organization") + // Parse reset date from "resets on 01/01" var resetsAt: Date? if let resetMatch = stripped.range(of: #"resets on (\d{2}/\d{2})"#, options: .regularExpression) { @@ -423,7 +439,24 @@ public struct KiroStatusProbe: Sendable { } } - // Require at least one key pattern to match to avoid silent failures + // Managed plans in new format may omit usage metrics. Only fall back to zeros when + // we did not parse any usage values, so we do not mask real metrics. + if matchedNewFormat, isManagedPlan, !matchedPercent, !matchedCredits { + // Managed plans don't expose credits; return snapshot with plan name only + return KiroUsageSnapshot( + planName: planName, + creditsUsed: 0, + creditsTotal: 0, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + updatedAt: Date()) + } + + // Require at least one key pattern to match to avoid silent failures. + // Managed plans without usage data return early above. if !matchedPercent, !matchedCredits { throw KiroStatusProbeError.parseError( "No recognizable usage patterns found. Kiro CLI output format may have changed.") @@ -482,5 +515,7 @@ public struct KiroStatusProbe: Sendable { return stripped.contains("covered in plan") || stripped.contains("resets on") || stripped.contains("bonus credits") + || stripped.contains("plan:") + || stripped.contains("managed by admin") } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 432411efd..debe08288 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -58,9 +58,36 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.invalidCredentials } + // Historically, MiniMax API token fetching used a China endpoint by default in some configurations. If the + // user has no persisted region and we default to `.global`, retry the China endpoint when the global host + // rejects the token so upgrades don't regress existing setups. + if region != .global { + return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now) + } + + do { + return try await self.fetchUsageOnce(apiToken: cleaned, region: .global, now: now) + } catch let error as MiniMaxUsageError { + guard case .invalidCredentials = error else { throw error } + Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host") + do { + return try await self.fetchUsageOnce(apiToken: cleaned, region: .chinaMainland, now: now) + } catch { + // Preserve the original invalid-credentials error so the fetch pipeline can fall back to web. + Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials") + throw MiniMaxUsageError.invalidCredentials + } + } + } + + private static func fetchUsageOnce( + apiToken: String, + region: MiniMaxAPIRegion, + now: Date) async throws -> MiniMaxUsageSnapshot + { var request = URLRequest(url: region.apiRemainsURL) request.httpMethod = "GET" - request.setValue("Bearer \(cleaned)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("CodexBar", forHTTPHeaderField: "MM-API-Source") diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift new file mode 100644 index 000000000..4702609fa --- /dev/null +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -0,0 +1,76 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum OllamaProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .ollama, + metadata: ProviderMetadata( + id: .ollama, + displayName: "Ollama", + sessionLabel: "Session", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Ollama usage", + cliName: "ollama", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://ollama.com/settings", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .ollama, + iconResourceName: "ProviderIcon-ollama", + color: ProviderColor(red: 136 / 255, green: 136 / 255, blue: 136 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Ollama cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OllamaStatusFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "ollama", + versionDetector: nil)) + } +} + +struct OllamaStatusFetchStrategy: ProviderFetchStrategy { + let id: String = "ollama.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.ollama?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = OllamaUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let isManualMode = context.settings?.ollama?.cookieSource == .manual + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.ollama).verbose(msg) } + : nil + let snap = try await fetcher.fetch( + cookieHeaderOverride: manual, + manualCookieMode: isManualMode, + logger: logger) + return self.makeResult( + usage: snap.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.ollama?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift new file mode 100644 index 000000000..9e5c4b068 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -0,0 +1,614 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit +#endif + +private let ollamaSessionCookieNames: Set = [ + "session", + "ollama_session", + "__Host-ollama_session", + "__Secure-next-auth.session-token", + "next-auth.session-token", +] + +private func isRecognizedOllamaSessionCookieName(_ name: String) -> Bool { + if ollamaSessionCookieNames.contains(name) { return true } + // next-auth can split tokens into chunked cookies: `.0`, `.1`, ... + return name.hasPrefix("__Secure-next-auth.session-token.") || + name.hasPrefix("next-auth.session-token.") +} + +private func hasRecognizedOllamaSessionCookie(in header: String) -> Bool { + CookieHeaderNormalizer.pairs(from: header).contains { pair in + isRecognizedOllamaSessionCookieName(pair.name) + } +} + +public enum OllamaUsageError: LocalizedError, Sendable { + case notLoggedIn + case invalidCredentials + case parseFailed(String) + case networkError(String) + case noSessionCookie + + public var errorDescription: String? { + switch self { + case .notLoggedIn: + "Not logged in to Ollama. Please log in via ollama.com/settings." + case .invalidCredentials: + "Ollama session cookie expired. Please log in again." + case let .parseFailed(message): + "Could not parse Ollama usage: \(message)" + case let .networkError(message): + "Ollama request failed: \(message)" + case .noSessionCookie: + "No Ollama session cookie found. Please log in to ollama.com in your browser." + } + } +} + +#if os(macOS) +private let ollamaCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.ollama]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum OllamaCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["ollama.com", "www.ollama.com"] + static let defaultPreferredBrowsers: [Browser] = [.chrome] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + public static func importSessions( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + allowFallbackBrowsers: Bool = false, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let log: (String) -> Void = { msg in logger?("[ollama-cookie] \(msg)") } + let preferredSources = preferredBrowsers.isEmpty + ? ollamaCookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + let preferredCandidates = self.collectSessionInfo(from: preferredSources, logger: log) + return try self.selectSessionInfosWithFallback( + preferredCandidates: preferredCandidates, + allowFallbackBrowsers: allowFallbackBrowsers, + loadFallbackCandidates: { + guard !preferredBrowsers.isEmpty else { return [] } + let fallbackSources = self.fallbackBrowserSources( + browserDetection: browserDetection, + excluding: preferredSources) + guard !fallbackSources.isEmpty else { return [] } + log("No recognized Ollama session in preferred browsers; trying fallback import order") + return self.collectSessionInfo(from: fallbackSources, logger: log) + }, + logger: log) + } + + public static func importSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + allowFallbackBrowsers: Bool = false, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + allowFallbackBrowsers: allowFallbackBrowsers, + logger: logger) + guard let first = sessions.first else { + throw OllamaUsageError.noSessionCookie + } + return first + } + + static func selectSessionInfos( + from candidates: [SessionInfo], + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + var recognized: [SessionInfo] = [] + for candidate in candidates { + let names = candidate.cookies.map(\.name).joined(separator: ", ") + logger?("\(candidate.sourceLabel) cookies: \(names)") + if self.containsRecognizedSessionCookie(in: candidate.cookies) { + logger?("Found Ollama session cookie in \(candidate.sourceLabel)") + recognized.append(candidate) + } else { + logger?("\(candidate.sourceLabel) cookies found, but no recognized session cookie present") + } + } + guard !recognized.isEmpty else { + throw OllamaUsageError.noSessionCookie + } + return recognized + } + + static func selectSessionInfo( + from candidates: [SessionInfo], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + guard let first = try self.selectSessionInfos(from: candidates, logger: logger).first else { + throw OllamaUsageError.noSessionCookie + } + return first + } + + static func selectSessionInfosWithFallback( + preferredCandidates: [SessionInfo], + allowFallbackBrowsers: Bool, + loadFallbackCandidates: () -> [SessionInfo], + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + guard allowFallbackBrowsers else { + return try self.selectSessionInfos(from: preferredCandidates, logger: logger) + } + do { + return try self.selectSessionInfos(from: preferredCandidates, logger: logger) + } catch OllamaUsageError.noSessionCookie { + let fallbackCandidates = loadFallbackCandidates() + return try self.selectSessionInfos(from: fallbackCandidates, logger: logger) + } + } + + static func selectSessionInfoWithFallback( + preferredCandidates: [SessionInfo], + allowFallbackBrowsers: Bool, + loadFallbackCandidates: () -> [SessionInfo], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + guard let first = try self.selectSessionInfosWithFallback( + preferredCandidates: preferredCandidates, + allowFallbackBrowsers: allowFallbackBrowsers, + loadFallbackCandidates: loadFallbackCandidates, + logger: logger).first + else { + throw OllamaUsageError.noSessionCookie + } + return first + } + + private static func fallbackBrowserSources( + browserDetection: BrowserDetection, + excluding triedSources: [Browser]) -> [Browser] + { + let tried = Set(triedSources) + return ollamaCookieImportOrder.cookieImportCandidates(using: browserDetection) + .filter { !tried.contains($0) } + } + + private static func collectSessionInfo( + from browserSources: [Browser], + logger: @escaping (String) -> Void) -> [SessionInfo] + { + var candidates: [SessionInfo] = [] + for browserSource in browserSources { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: logger) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard !cookies.isEmpty else { continue } + candidates.append(SessionInfo(cookies: cookies, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + logger("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + return candidates + } + + private static func containsRecognizedSessionCookie(in cookies: [HTTPCookie]) -> Bool { + cookies.contains { cookie in + isRecognizedOllamaSessionCookieName(cookie.name) + } + } +} +#endif + +public struct OllamaUsageFetcher: Sendable { + private static let settingsURL = URL(string: "https://ollama.com/settings")! + @MainActor private static var recentDumps: [String] = [] + + private struct CookieCandidate: Sendable { + let cookieHeader: String + let sourceLabel: String + } + + enum RetryableParseFailure: Error, Sendable { + case missingUsageData + } + + public let browserDetection: BrowserDetection + private let makeURLSession: @Sendable (URLSessionTaskDelegate?) -> URLSession + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + self.makeURLSession = { delegate in + URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + } + } + + init( + browserDetection: BrowserDetection, + makeURLSession: @escaping @Sendable (URLSessionTaskDelegate?) -> URLSession) + { + self.browserDetection = browserDetection + self.makeURLSession = makeURLSession + } + + public func fetch( + cookieHeaderOverride: String? = nil, + manualCookieMode: Bool = false, + logger: ((String) -> Void)? = nil, + now: Date = Date()) async throws -> OllamaUsageSnapshot + { + let cookieCandidates = try await self.resolveCookieCandidates( + override: cookieHeaderOverride, + manualCookieMode: manualCookieMode, + logger: logger) + return try await self.fetchUsingCookieCandidates( + cookieCandidates, + logger: logger, + now: now) + } + + static func shouldRetryWithNextCookieCandidate(after error: Error) -> Bool { + switch error { + case OllamaUsageError.invalidCredentials, OllamaUsageError.notLoggedIn: + true + case RetryableParseFailure.missingUsageData: + true + default: + false + } + } + + private func fetchUsingCookieCandidates( + _ candidates: [CookieCandidate], + logger: ((String) -> Void)?, + now: Date) async throws -> OllamaUsageSnapshot + { + do { + return try await ProviderCandidateRetryRunner.run( + candidates, + shouldRetry: { error in + Self.shouldRetryWithNextCookieCandidate(after: error) + }, + onRetry: { candidate, _ in + logger?("[ollama] Auth failed for \(candidate.sourceLabel); trying next cookie candidate") + }, + attempt: { candidate in + logger?("[ollama] Using cookies from \(candidate.sourceLabel)") + let names = self.cookieNames(from: candidate.cookieHeader) + if !names.isEmpty { + logger?("[ollama] Cookie names: \(names.joined(separator: ", "))") + } + + let diagnostics = RedirectDiagnostics(cookieHeader: candidate.cookieHeader, logger: logger) + do { + let (html, responseInfo) = try await self.fetchHTMLWithDiagnostics( + cookieHeader: candidate.cookieHeader, + diagnostics: diagnostics) + if let logger { + self.logDiagnostics(responseInfo: responseInfo, diagnostics: diagnostics, logger: logger) + } + do { + return try Self.parseSnapshotForRetry(html: html, now: now) + } catch { + let surfacedError = Self.surfacedError(from: error) + if let logger { + logger("[ollama] Parse failed: \(surfacedError.localizedDescription)") + self.logHTMLHints(html: html, logger: logger) + } + throw error + } + } catch { + if let logger { + self.logDiagnostics(responseInfo: nil, diagnostics: diagnostics, logger: logger) + } + throw error + } + }) + } catch ProviderCandidateRetryRunnerError.noCandidates { + throw OllamaUsageError.noSessionCookie + } catch { + throw Self.surfacedError(from: error) + } + } + + private static func parseSnapshotForRetry(html: String, now: Date) throws -> OllamaUsageSnapshot { + switch OllamaUsageParser.parseClassified(html: html, now: now) { + case let .success(snapshot): + return snapshot + case .failure(.notLoggedIn): + throw OllamaUsageError.notLoggedIn + case .failure(.missingUsageData): + throw RetryableParseFailure.missingUsageData + } + } + + private static func surfacedError(from error: Error) -> Error { + switch error { + case RetryableParseFailure.missingUsageData: + OllamaUsageError.parseFailed("Missing Ollama usage data.") + default: + error + } + } + + private func resolveCookieCandidates( + override: String?, + manualCookieMode: Bool, + logger: ((String) -> Void)?) async throws -> [CookieCandidate] + { + if let manualHeader = try Self.resolveManualCookieHeader( + override: override, + manualCookieMode: manualCookieMode, + logger: logger) + { + return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")] + } + #if os(macOS) + let sessions = try OllamaCookieImporter.importSessions(browserDetection: self.browserDetection, logger: logger) + return sessions.map { session in + CookieCandidate(cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) + } + #else + throw OllamaUsageError.noSessionCookie + #endif + } + + public func debugRawProbe( + cookieHeaderOverride: String? = nil, + manualCookieMode: Bool = false) async -> String + { + let stamp = ISO8601DateFormatter().string(from: Date()) + var lines: [String] = [] + lines.append("=== Ollama Debug Probe @ \(stamp) ===") + lines.append("") + + do { + let cookieHeader = try await self.resolveCookieHeader( + override: cookieHeaderOverride, + manualCookieMode: manualCookieMode, + logger: { msg in lines.append("[cookie] \(msg)") }) + let diagnostics = RedirectDiagnostics(cookieHeader: cookieHeader, logger: nil) + let cookieNames = CookieHeaderNormalizer.pairs(from: cookieHeader).map(\.name) + lines.append("Cookie names: \(cookieNames.joined(separator: ", "))") + + let (snapshot, responseInfo) = try await self.fetchWithDiagnostics( + cookieHeader: cookieHeader, + diagnostics: diagnostics) + + lines.append("") + lines.append("Fetch Success") + lines.append("Status: \(responseInfo.statusCode) \(responseInfo.url)") + + if !diagnostics.redirects.isEmpty { + lines.append("") + lines.append("Redirects:") + for entry in diagnostics.redirects { + lines.append(" \(entry)") + } + } + + lines.append("") + lines.append("Plan: \(snapshot.planName ?? "unknown")") + lines.append("Session: \(snapshot.sessionUsedPercent?.description ?? "nil")%") + lines.append("Weekly: \(snapshot.weeklyUsedPercent?.description ?? "nil")%") + lines.append("Session resetsAt: \(snapshot.sessionResetsAt?.description ?? "nil")") + lines.append("Weekly resetsAt: \(snapshot.weeklyResetsAt?.description ?? "nil")") + + let output = lines.joined(separator: "\n") + Task { @MainActor in Self.recordDump(output) } + return output + } catch { + lines.append("") + lines.append("Probe Failed: \(error.localizedDescription)") + let output = lines.joined(separator: "\n") + Task { @MainActor in Self.recordDump(output) } + return output + } + } + + public static func latestDumps() async -> String { + await MainActor.run { + let result = Self.recentDumps.joined(separator: "\n\n---\n\n") + return result.isEmpty ? "No Ollama probe dumps captured yet." : result + } + } + + private func resolveCookieHeader( + override: String?, + manualCookieMode: Bool, + logger: ((String) -> Void)?) async throws -> String + { + if let manualHeader = try Self.resolveManualCookieHeader( + override: override, + manualCookieMode: manualCookieMode, + logger: logger) + { + return manualHeader + } + #if os(macOS) + let session = try OllamaCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger) + logger?("[ollama] Using cookies from \(session.sourceLabel)") + return session.cookieHeader + #else + throw OllamaUsageError.noSessionCookie + #endif + } + + static func resolveManualCookieHeader( + override: String?, + manualCookieMode: Bool, + logger: ((String) -> Void)? = nil) throws -> String? + { + if let override = CookieHeaderNormalizer.normalize(override) { + guard hasRecognizedOllamaSessionCookie(in: override) else { + logger?("[ollama] Manual cookie header missing recognized session cookie") + throw OllamaUsageError.noSessionCookie + } + logger?("[ollama] Using manual cookie header") + return override + } + if manualCookieMode { + throw OllamaUsageError.noSessionCookie + } + return nil + } + + private func fetchWithDiagnostics( + cookieHeader: String, + diagnostics: RedirectDiagnostics, + now: Date = Date()) async throws -> (OllamaUsageSnapshot, ResponseInfo) + { + let (html, responseInfo) = try await self.fetchHTMLWithDiagnostics( + cookieHeader: cookieHeader, + diagnostics: diagnostics) + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + return (snapshot, responseInfo) + } + + private func fetchHTMLWithDiagnostics( + cookieHeader: String, + diagnostics: RedirectDiagnostics) async throws -> (String, ResponseInfo) + { + var request = URLRequest(url: Self.settingsURL) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "accept") + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + forHTTPHeaderField: "user-agent") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") + request.setValue("https://ollama.com", forHTTPHeaderField: "origin") + request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") + + let session = self.makeURLSession(diagnostics) + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw OllamaUsageError.networkError("Invalid response") + } + let responseInfo = ResponseInfo( + statusCode: httpResponse.statusCode, + url: httpResponse.url?.absoluteString ?? "unknown") + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw OllamaUsageError.invalidCredentials + } + throw OllamaUsageError.networkError("HTTP \(httpResponse.statusCode)") + } + + let html = String(data: data, encoding: .utf8) ?? "" + return (html, responseInfo) + } + + @MainActor private static func recordDump(_ text: String) { + if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } + self.recentDumps.append(text) + } + + private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let cookieHeader: String + private let logger: ((String) -> Void)? + var redirects: [String] = [] + + init(cookieHeader: String, logger: ((String) -> Void)?) { + self.cookieHeader = cookieHeader + self.logger = logger + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + let from = response.url?.absoluteString ?? "unknown" + let to = request.url?.absoluteString ?? "unknown" + self.redirects.append("\(response.statusCode) \(from) -> \(to)") + var updated = request + if OllamaUsageFetcher.shouldAttachCookie(to: request.url), !self.cookieHeader.isEmpty { + updated.setValue(self.cookieHeader, forHTTPHeaderField: "Cookie") + } else { + updated.setValue(nil, forHTTPHeaderField: "Cookie") + } + if let referer = response.url?.absoluteString { + updated.setValue(referer, forHTTPHeaderField: "referer") + } + if let logger { + logger("[ollama] Redirect \(response.statusCode) \(from) -> \(to)") + } + completionHandler(updated) + } + } + + private struct ResponseInfo: Sendable { + let statusCode: Int + let url: String + } + + private func logDiagnostics( + responseInfo: ResponseInfo?, + diagnostics: RedirectDiagnostics, + logger: (String) -> Void) + { + if let responseInfo { + logger("[ollama] Response: \(responseInfo.statusCode) \(responseInfo.url)") + } + if !diagnostics.redirects.isEmpty { + logger("[ollama] Redirects:") + for entry in diagnostics.redirects { + logger("[ollama] \(entry)") + } + } + } + + private func logHTMLHints(html: String, logger: (String) -> Void) { + logger("[ollama] HTML length: \(html.utf8.count) bytes") + logger("[ollama] Contains Cloud Usage: \(html.contains("Cloud Usage"))") + logger("[ollama] Contains Session usage: \(html.contains("Session usage"))") + logger("[ollama] Contains Hourly usage: \(html.contains("Hourly usage"))") + logger("[ollama] Contains Weekly usage: \(html.contains("Weekly usage"))") + } + + private func cookieNames(from header: String) -> [String] { + header.split(separator: ";", omittingEmptySubsequences: false).compactMap { part in + let trimmed = part.trimmingCharacters(in: .whitespaces) + guard let idx = trimmed.firstIndex(of: "=") else { return nil } + let name = trimmed[.. Bool { + guard let host = url?.host?.lowercased() else { return false } + if host == "ollama.com" || host == "www.ollama.com" { return true } + return host.hasSuffix(".ollama.com") + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift new file mode 100644 index 000000000..f93f35864 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift @@ -0,0 +1,170 @@ +import Foundation + +enum OllamaUsageParser { + private static let primaryUsageLabels = ["Session usage", "Hourly usage"] + + enum ParseFailure: Sendable, Equatable { + case notLoggedIn + case missingUsageData + } + + enum ClassifiedParseResult: Sendable { + case success(OllamaUsageSnapshot) + case failure(ParseFailure) + } + + static func parse(html: String, now: Date = Date()) throws -> OllamaUsageSnapshot { + switch self.parseClassified(html: html, now: now) { + case let .success(snapshot): + return snapshot + case .failure(.notLoggedIn): + throw OllamaUsageError.notLoggedIn + case .failure(.missingUsageData): + throw OllamaUsageError.parseFailed("Missing Ollama usage data.") + } + } + + static func parseClassified(html: String, now: Date = Date()) -> ClassifiedParseResult { + let plan = self.parsePlanName(html) + let email = self.parseAccountEmail(html) + let session = self.parseUsageBlock(labels: self.primaryUsageLabels, html: html) + let weekly = self.parseUsageBlock(label: "Weekly usage", html: html) + + if session == nil, weekly == nil { + if self.looksSignedOut(html) { + return .failure(.notLoggedIn) + } + return .failure(.missingUsageData) + } + + return .success(OllamaUsageSnapshot( + planName: plan, + accountEmail: email, + sessionUsedPercent: session?.usedPercent, + weeklyUsedPercent: weekly?.usedPercent, + sessionResetsAt: session?.resetsAt, + weeklyResetsAt: weekly?.resetsAt, + updatedAt: now)) + } + + private struct UsageBlock: Sendable { + let usedPercent: Double + let resetsAt: Date? + } + + private static func parsePlanName(_ html: String) -> String? { + let pattern = #"Cloud Usage\s*\s*]*>([^<]+)"# + guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators]) + else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func parseAccountEmail(_ html: String) -> String? { + let pattern = #"id=\"header-email\"[^>]*>([^<]+)<"# + guard let raw = self.firstCapture(in: html, pattern: pattern, options: [.dotMatchesLineSeparators]) + else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.contains("@") else { return nil } + return trimmed + } + + private static func parseUsageBlock(label: String, html: String) -> UsageBlock? { + guard let labelRange = html.range(of: label) else { return nil } + let tail = String(html[labelRange.upperBound...]) + let window = String(tail.prefix(800)) + + guard let usedPercent = self.parsePercent(in: window) else { return nil } + let resetsAt = self.parseISODate(in: window) + return UsageBlock(usedPercent: usedPercent, resetsAt: resetsAt) + } + + private static func parseUsageBlock(labels: [String], html: String) -> UsageBlock? { + for label in labels { + if let parsed = self.parseUsageBlock(label: label, html: html) { + return parsed + } + } + return nil + } + + private static func parsePercent(in text: String) -> Double? { + let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"# + if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) { + return Double(raw) + } + let widthPattern = #"width:\s*([0-9]+(?:\.[0-9]+)?)%"# + if let raw = self.firstCapture(in: text, pattern: widthPattern, options: [.caseInsensitive]) { + return Double(raw) + } + return nil + } + + private static func parseISODate(in text: String) -> Date? { + let pattern = #"data-time=\"([^\"]+)\""# + guard let raw = self.firstCapture(in: text, pattern: pattern, options: []) else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { + return date + } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: raw) + } + + private static func firstCapture( + in text: String, + pattern: String, + options: NSRegularExpression.Options) -> String? + { + guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return nil } + return Self.performMatch(regex: regex, text: text) + } + + private static func performMatch( + regex: NSRegularExpression, + text: String) -> String? + { + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) + else { return nil } + return String(text[captureRange]) + } + + private static func looksSignedOut(_ html: String) -> Bool { + let lower = html.lowercased() + let hasSignInHeading = lower.contains("sign in to ollama") || lower.contains("log in to ollama") + let hasAuthRoute = lower.contains("/api/auth/signin") || lower.contains("/auth/signin") + let hasLoginRoute = lower.contains("action=\"/login\"") + || lower.contains("action='/login'") + || lower.contains("href=\"/login\"") + || lower.contains("href='/login'") + || lower.contains("action=\"/signin\"") + || lower.contains("action='/signin'") + || lower.contains("href=\"/signin\"") + || lower.contains("href='/signin'") + let hasPasswordField = lower.contains("type=\"password\"") + || lower.contains("type='password'") + || lower.contains("name=\"password\"") + || lower.contains("name='password'") + let hasEmailField = lower.contains("type=\"email\"") + || lower.contains("type='email'") + || lower.contains("name=\"email\"") + || lower.contains("name='email'") + let hasAuthForm = lower.contains(" UsageSnapshot { + let sessionWindow = self.makeWindow( + usedPercent: self.sessionUsedPercent, + resetsAt: self.sessionResetsAt) + let weeklyWindow = self.makeWindow( + usedPercent: self.weeklyUsedPercent, + resetsAt: self.weeklyResetsAt) + + let plan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = self.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let identity = ProviderIdentitySnapshot( + providerID: .ollama, + accountEmail: email?.isEmpty == false ? email : nil, + accountOrganization: nil, + loginMethod: plan?.isEmpty == false ? plan : nil) + + return UsageSnapshot( + primary: sessionWindow, + secondary: weeklyWindow, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private func makeWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + guard let usedPercent else { return nil } + let clamped = min(100, max(0, usedPercent)) + return RateWindow( + usedPercent: clamped, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: nil) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 289c779f3..ed27f1d4f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -161,6 +161,10 @@ public struct OpenCodeUsageFetcher: Sendable { if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } + if self.isExplicitNullPayload(text: text) { + Self.log.warning("OpenCode subscription GET returned null; skipping POST fallback.") + throw self.missingSubscriptionDataError(workspaceID: workspaceID) + } if self.parseSubscriptionJSON(text: text, now: Date()) == nil, self.extractDouble( pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, @@ -178,11 +182,34 @@ public struct OpenCodeUsageFetcher: Sendable { if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } + if self.isExplicitNullPayload(text: fallback) { + Self.log.warning("OpenCode subscription POST returned null.") + throw self.missingSubscriptionDataError(workspaceID: workspaceID) + } return fallback } return text } + private static func isExplicitNullPayload(text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.caseInsensitiveCompare("null") == .orderedSame { + return true + } + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return false + } + return object is NSNull + } + + private static func missingSubscriptionDataError(workspaceID: String) -> OpenCodeUsageError { + OpenCodeUsageError.apiError( + "No subscription usage data was returned for workspace \(workspaceID). " + + "This usually means this workspace does not have OpenCode Black usage data.") + } + private static func normalizeWorkspaceID(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) @@ -409,18 +436,27 @@ public struct OpenCodeUsageFetcher: Sendable { private static func extractServerErrorMessage(from text: String) -> String? { guard let data = text.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data, options: []), - let dict = object as? [String: Any] + let object = try? JSONSerialization.jsonObject(with: data, options: []) else { + // If it's not JSON, try to extract error from HTML if possible + if let match = text.range(of: #"(?i)([^<]+)"#, options: .regularExpression) { + return String(text[match].dropFirst(7).dropLast(8)).trimmingCharacters(in: .whitespacesAndNewlines) + } return nil } + guard let dict = object as? [String: Any] else { return nil } + if let message = dict["message"] as? String, !message.isEmpty { return message } if let error = dict["error"] as? String, !error.isEmpty { return error } + // Check for common error fields in some frameworks + if let detail = dict["detail"] as? String, !detail.isEmpty { + return detail + } return nil } diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift new file mode 100644 index 000000000..711954198 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift @@ -0,0 +1,83 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum OpenRouterProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .openrouter, + metadata: ProviderMetadata( + id: .openrouter, + displayName: "OpenRouter", + sessionLabel: "Credits", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Credit balance from OpenRouter API", + toggleTitle: "Show OpenRouter usage", + cliName: "openrouter", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://openrouter.ai/settings/credits", + statusPageURL: nil, + statusLinkURL: "https://status.openrouter.ai"), + branding: ProviderBranding( + iconStyle: .openrouter, + iconResourceName: "ProviderIcon-openrouter", + color: ProviderColor(red: 100 / 255, green: 103 / 255, blue: 242 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "OpenRouter cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenRouterAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "openrouter", + aliases: ["or"], + versionDetector: nil)) + } +} + +struct OpenRouterAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "openrouter.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw OpenRouterSettingsError.missingToken + } + let usage = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.openRouterToken(environment: environment) + } +} + +/// Errors related to OpenRouter settings +public enum OpenRouterSettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift new file mode 100644 index 000000000..e5e3f4d78 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Reads OpenRouter settings from environment variables +public enum OpenRouterSettingsReader { + /// Environment variable key for OpenRouter API token + public static let envKey = "OPENROUTER_API_KEY" + + /// Returns the API token from environment if present and non-empty + public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.envKey]) + } + + /// Returns the API URL, defaulting to production endpoint + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["OPENROUTER_API_URL"], + let url = URL(string: cleaned(override) ?? "") + { + return url + } + return URL(string: "https://openrouter.ai/api/v1")! + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift new file mode 100644 index 000000000..fbcc3e44c --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -0,0 +1,432 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// OpenRouter credits API response +public struct OpenRouterCreditsResponse: Decodable, Sendable { + public let data: OpenRouterCreditsData +} + +/// OpenRouter credits data +public struct OpenRouterCreditsData: Decodable, Sendable { + /// Total credits ever added to the account (in USD) + public let totalCredits: Double + /// Total credits used (in USD) + public let totalUsage: Double + + private enum CodingKeys: String, CodingKey { + case totalCredits = "total_credits" + case totalUsage = "total_usage" + } + + /// Remaining credits (total - usage) + public var balance: Double { + max(0, self.totalCredits - self.totalUsage) + } + + /// Usage percentage (0-100) + public var usedPercent: Double { + guard self.totalCredits > 0 else { return 0 } + return min(100, (self.totalUsage / self.totalCredits) * 100) + } +} + +/// OpenRouter key info API response +public struct OpenRouterKeyResponse: Decodable, Sendable { + public let data: OpenRouterKeyData +} + +/// OpenRouter key data with quota and rate limit info +public struct OpenRouterKeyData: Decodable, Sendable { + /// Rate limit per interval + public let rateLimit: OpenRouterRateLimit? + /// Usage limits + public let limit: Double? + /// Current usage + public let usage: Double? + + private enum CodingKeys: String, CodingKey { + case rateLimit = "rate_limit" + case limit + case usage + } +} + +/// OpenRouter rate limit info +public struct OpenRouterRateLimit: Codable, Sendable { + /// Number of requests allowed + public let requests: Int + /// Interval for the rate limit (e.g., "10s", "1m") + public let interval: String +} + +public enum OpenRouterKeyQuotaStatus: String, Codable, Sendable { + case available + case noLimitConfigured + case unavailable +} + +/// Complete OpenRouter usage snapshot +public struct OpenRouterUsageSnapshot: Codable, Sendable { + public let totalCredits: Double + public let totalUsage: Double + public let balance: Double + public let usedPercent: Double + public let keyDataFetched: Bool + public let keyLimit: Double? + public let keyUsage: Double? + public let rateLimit: OpenRouterRateLimit? + public let updatedAt: Date + + public init( + totalCredits: Double, + totalUsage: Double, + balance: Double, + usedPercent: Double, + keyDataFetched: Bool = false, + keyLimit: Double? = nil, + keyUsage: Double? = nil, + rateLimit: OpenRouterRateLimit?, + updatedAt: Date) + { + self.totalCredits = totalCredits + self.totalUsage = totalUsage + self.balance = balance + self.usedPercent = usedPercent + self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil + self.keyLimit = keyLimit + self.keyUsage = keyUsage + self.rateLimit = rateLimit + self.updatedAt = updatedAt + } + + /// Returns true if this snapshot contains valid data + public var isValid: Bool { + self.totalCredits >= 0 + } + + public var hasValidKeyQuota: Bool { + guard self.keyDataFetched, + let keyLimit, + let keyUsage + else { + return false + } + return keyLimit > 0 && keyUsage >= 0 + } + + public var keyQuotaStatus: OpenRouterKeyQuotaStatus { + if self.hasValidKeyQuota { + return .available + } + guard self.keyDataFetched else { + return .unavailable + } + if let keyLimit, keyLimit > 0 { + return .unavailable + } + return .noLimitConfigured + } + + public var keyRemaining: Double? { + guard self.hasValidKeyQuota, + let keyLimit, + let keyUsage + else { + return nil + } + return max(0, keyLimit - keyUsage) + } + + public var keyUsedPercent: Double? { + guard self.hasValidKeyQuota, + let keyLimit, + let keyUsage + else { + return nil + } + return min(100, max(0, (keyUsage / keyLimit) * 100)) + } +} + +extension OpenRouterUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let primary: RateWindow? = if let keyUsedPercent { + RateWindow( + usedPercent: keyUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil) + } else { + nil + } + + // Format balance for identity display + let balanceStr = String(format: "$%.2f", balance) + let identity = ProviderIdentitySnapshot( + providerID: .openrouter, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balanceStr)") + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + openRouterUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } +} + +/// Fetches usage stats from the OpenRouter API +public struct OpenRouterUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.openRouterUsage) + private static let rateLimitTimeoutSeconds: TimeInterval = 1.0 + private static let creditsRequestTimeoutSeconds: TimeInterval = 15 + private static let maxErrorBodyLength = 240 + private static let maxDebugErrorBodyLength = 2000 + private static let debugFullErrorBodiesEnvKey = "CODEXBAR_DEBUG_OPENROUTER_ERROR_BODIES" + private static let httpRefererEnvKey = "OPENROUTER_HTTP_REFERER" + private static let clientTitleEnvKey = "OPENROUTER_X_TITLE" + private static let defaultClientTitle = "CodexBar" + + /// Fetches credits usage from OpenRouter using the provided API key + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> OpenRouterUsageSnapshot + { + guard !apiKey.isEmpty else { + throw OpenRouterUsageError.invalidCredentials + } + + let baseURL = OpenRouterSettingsReader.apiURL(environment: environment) + let creditsURL = baseURL.appendingPathComponent("credits") + + var request = URLRequest(url: creditsURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.creditsRequestTimeoutSeconds + if let referer = Self.sanitizedHeaderValue(environment[self.httpRefererEnvKey]) { + request.setValue(referer, forHTTPHeaderField: "HTTP-Referer") + } + let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle + request.setValue(title, forHTTPHeaderField: "X-Title") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenRouterUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) + if Self.debugFullErrorBodiesEnabled(environment: environment), + let debugBody = Self.redactedDebugResponseBody(data) + { + Self.log.debug("OpenRouter non-200 body (redacted): \(LogRedactor.redact(debugBody))") + } + Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + do { + let decoder = JSONDecoder() + let creditsResponse = try decoder.decode(OpenRouterCreditsResponse.self, from: data) + + // Optionally fetch key quota/rate-limit info from /key endpoint, but keep this bounded so + // credits updates are not blocked by a slow or unavailable secondary endpoint. + let keyFetch = await fetchKeyData( + apiKey: apiKey, + baseURL: baseURL, + timeoutSeconds: Self.rateLimitTimeoutSeconds) + + return OpenRouterUsageSnapshot( + totalCredits: creditsResponse.data.totalCredits, + totalUsage: creditsResponse.data.totalUsage, + balance: creditsResponse.data.balance, + usedPercent: creditsResponse.data.usedPercent, + keyDataFetched: keyFetch.fetched, + keyLimit: keyFetch.data?.limit, + keyUsage: keyFetch.data?.usage, + rateLimit: keyFetch.data?.rateLimit, + updatedAt: Date()) + } catch let error as DecodingError { + Self.log.error("OpenRouter JSON decoding error: \(error.localizedDescription)") + throw OpenRouterUsageError.parseFailed(error.localizedDescription) + } catch let error as OpenRouterUsageError { + throw error + } catch { + Self.log.error("OpenRouter parsing error: \(error.localizedDescription)") + throw OpenRouterUsageError.parseFailed(error.localizedDescription) + } + } + + /// Fetches key quota/rate-limit info from /key endpoint + private struct OpenRouterKeyFetchResult: Sendable { + let data: OpenRouterKeyData? + let fetched: Bool + } + + private static func fetchKeyData( + apiKey: String, + baseURL: URL, + timeoutSeconds: TimeInterval) async -> OpenRouterKeyFetchResult + { + let timeout = max(0.1, timeoutSeconds) + let timeoutNanoseconds = UInt64(timeout * 1_000_000_000) + + return await withTaskGroup(of: OpenRouterKeyFetchResult.self) { group in + group.addTask { + await Self.fetchKeyDataRequest( + apiKey: apiKey, + baseURL: baseURL, + timeoutSeconds: timeout) + } + group.addTask { + do { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + } catch { + // Cancelled because the /key request finished first. + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + guard !Task.isCancelled else { + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + Self.log.debug("OpenRouter /key enrichment timed out after \(timeout)s") + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + + let result = await group.next() + group.cancelAll() + if let result { + return result + } + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + } + + private static func fetchKeyDataRequest( + apiKey: String, + baseURL: URL, + timeoutSeconds: TimeInterval) async -> OpenRouterKeyFetchResult + { + let keyURL = baseURL.appendingPathComponent("key") + + var request = URLRequest(url: keyURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = timeoutSeconds + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + + let decoder = JSONDecoder() + let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data) + return OpenRouterKeyFetchResult(data: keyResponse.data, fetched: true) + } catch { + Self.log.debug("Failed to fetch OpenRouter /key enrichment: \(error.localizedDescription)") + return OpenRouterKeyFetchResult(data: nil, fetched: false) + } + } + + private static func debugFullErrorBodiesEnabled(environment: [String: String]) -> Bool { + environment[self.debugFullErrorBodiesEnvKey] == "1" + } + + private static func sanitizedHeaderValue(_ raw: String?) -> String? { + OpenRouterSettingsReader.cleaned(raw) + } + + private static func sanitizedResponseBodySummary(_ data: Data) -> String { + guard !data.isEmpty else { return "empty body" } + + guard let rawBody = String(bytes: data, encoding: .utf8) else { + return "non-text body (\(data.count) bytes)" + } + + let body = Self.redactSensitiveBodyContent(rawBody) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !body.isEmpty else { return "non-text body (\(data.count) bytes)" } + guard body.count > Self.maxErrorBodyLength else { return body } + + let index = body.index(body.startIndex, offsetBy: Self.maxErrorBodyLength) + return "\(body[.. String? { + guard let rawBody = String(bytes: data, encoding: .utf8) else { return nil } + + let body = Self.redactSensitiveBodyContent(rawBody) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { return nil } + guard body.count > Self.maxDebugErrorBodyLength else { return body } + + let index = body.index(body.startIndex, offsetBy: Self.maxDebugErrorBodyLength) + return "\(body[.. String { + let replacements: [(String, String)] = [ + (#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), + (#"(?i)(sk-or-v1-)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), + ( + #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#, + "$1[REDACTED]$3"), + ( + #"(?i)((?:api_?key|authorization|token|access_token|refresh_token)\s*[=:]\s*)([^,\s]+)"#, + "$1[REDACTED]"), + ] + + return replacements.reduce(text) { partial, replacement in + partial.replacingOccurrences( + of: replacement.0, + with: replacement.1, + options: .regularExpression) + } + } + + #if DEBUG + static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String { + self.sanitizedResponseBodySummary(Data(body.utf8)) + } + + static func _redactedDebugResponseBodyForTesting(_ body: String) -> String? { + self.redactedDebugResponseBody(Data(body.utf8)) + } + #endif +} + +/// Errors that can occur during OpenRouter usage fetching +public enum OpenRouterUsageError: LocalizedError, Sendable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Invalid OpenRouter API credentials" + case let .networkError(message): + "OpenRouter network error: \(message)" + case let .apiError(message): + "OpenRouter API error: \(message)" + case let .parseFailed(message): + "Failed to parse OpenRouter response: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift new file mode 100644 index 000000000..5b2048236 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift @@ -0,0 +1,31 @@ +import Foundation + +enum ProviderCandidateRetryRunnerError: Error, Sendable { + case noCandidates +} + +enum ProviderCandidateRetryRunner { + static func run( + _ candidates: [Candidate], + shouldRetry: (Error) -> Bool, + onRetry: (Candidate, Error) -> Void = { _, _ in }, + attempt: (Candidate) async throws -> Output) async throws -> Output + { + guard !candidates.isEmpty else { + throw ProviderCandidateRetryRunnerError.noCandidates + } + + for (index, candidate) in candidates.enumerated() { + do { + return try await attempt(candidate) + } catch { + let hasMoreCandidates = index + 1 < candidates.count + guard hasMoreCandidates, shouldRetry(error) else { + throw error + } + onRetry(candidate, error) + } + } + throw ProviderCandidateRetryRunnerError.noCandidates + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff83695..0e18e2c3f 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -70,7 +70,10 @@ public enum ProviderDescriptorRegistry { .jetbrains: JetBrainsProviderDescriptor.descriptor, .kimik2: KimiK2ProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, + .ollama: OllamaProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, + .openrouter: OpenRouterProviderDescriptor.descriptor, + .warp: WarpProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderInteractionContext.swift b/Sources/CodexBarCore/Providers/ProviderInteractionContext.swift index 9d0c23e51..448344951 100644 --- a/Sources/CodexBarCore/Providers/ProviderInteractionContext.swift +++ b/Sources/CodexBarCore/Providers/ProviderInteractionContext.swift @@ -8,3 +8,12 @@ public enum ProviderInteraction: Sendable, Equatable { public enum ProviderInteractionContext { @TaskLocal public static var current: ProviderInteraction = .background } + +public enum ProviderRefreshPhase: Sendable, Equatable { + case regular + case startup +} + +public enum ProviderRefreshContext { + @TaskLocal public static var current: ProviderRefreshPhase = .regular +} diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index f83cb9fd1..a6fe9d61e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -15,6 +15,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, + ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( @@ -31,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: kimi, augment: augment, amp: amp, + ollama: ollama, jetbrains: jetbrains) } @@ -167,6 +169,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct OllamaProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -180,6 +192,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? public let amp: AmpProviderSettings? + public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? public var jetbrainsIDEBasePath: String? { @@ -200,6 +213,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, amp: AmpProviderSettings?, + ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled @@ -215,6 +229,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.kimi = kimi self.augment = augment self.amp = amp + self.ollama = ollama self.jetbrains = jetbrains } } @@ -231,6 +246,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) } @@ -248,6 +264,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { @@ -268,6 +285,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value case let .amp(value): self.amp = value + case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value } } @@ -287,6 +305,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { kimi: self.kimi, augment: self.augment, amp: self.amp, + ollama: self.ollama, jetbrains: self.jetbrains) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 6b978775a..7c746cf2b 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -45,6 +45,14 @@ public enum ProviderTokenResolver { self.kimiK2Resolution(environment: environment)?.token } + public static func warpToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.warpResolution(environment: environment)?.token + } + + public static func openRouterToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.openRouterResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -100,6 +108,18 @@ public enum ProviderTokenResolver { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func warpResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(WarpSettingsReader.apiKey(environment: environment)) + } + + public static func openRouterResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb953..b6d75ebb1 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -20,7 +20,10 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case jetbrains case kimik2 case amp + case ollama case synthetic + case warp + case openrouter } // swiftformat:enable sortDeclarations @@ -43,7 +46,10 @@ public enum IconStyle: Sendable, CaseIterable { case augment case jetbrains case amp + case ollama case synthetic + case warp + case openrouter case combined } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift new file mode 100644 index 000000000..29506321c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -0,0 +1,69 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WarpProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .warp, + metadata: ProviderMetadata( + id: .warp, + displayName: "Warp", + sessionLabel: "Credits", + weeklyLabel: "Add-on credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Warp usage", + cliName: "warp", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://docs.warp.dev/reference/cli/api-keys", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .warp, + iconResourceName: "ProviderIcon-warp", + color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Warp cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WarpAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "warp", + aliases: ["warp-ai", "warp-terminal"], + versionDetector: nil)) + } +} + +struct WarpAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "warp.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw WarpUsageError.missingCredentials + } + let usage = try await WarpUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.warpToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift new file mode 100644 index 000000000..cd3c639c1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct WarpSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "WARP_API_KEY", + "WARP_TOKEN", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift new file mode 100644 index 000000000..79ec2e8f0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -0,0 +1,469 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct WarpUsageSnapshot: Sendable { + public let requestLimit: Int + public let requestsUsed: Int + public let nextRefreshTime: Date? + public let isUnlimited: Bool + public let updatedAt: Date + // Combined bonus credits (user-level + workspace-level) + public let bonusCreditsRemaining: Int + public let bonusCreditsTotal: Int + // Earliest expiring bonus batch with remaining credits + public let bonusNextExpiration: Date? + public let bonusNextExpirationRemaining: Int + + public init( + requestLimit: Int, + requestsUsed: Int, + nextRefreshTime: Date?, + isUnlimited: Bool, + updatedAt: Date, + bonusCreditsRemaining: Int = 0, + bonusCreditsTotal: Int = 0, + bonusNextExpiration: Date? = nil, + bonusNextExpirationRemaining: Int = 0) + { + self.requestLimit = requestLimit + self.requestsUsed = requestsUsed + self.nextRefreshTime = nextRefreshTime + self.isUnlimited = isUnlimited + self.updatedAt = updatedAt + self.bonusCreditsRemaining = bonusCreditsRemaining + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusNextExpiration = bonusNextExpiration + self.bonusNextExpirationRemaining = bonusNextExpirationRemaining + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double = if self.isUnlimited { + 0 + } else if self.requestLimit > 0 { + min(100, max(0, Double(self.requestsUsed) / Double(self.requestLimit) * 100)) + } else { + 0 + } + + let resetDescription: String? = if self.isUnlimited { + "Unlimited" + } else { + "\(self.requestsUsed)/\(self.requestLimit) credits" + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.isUnlimited ? nil : self.nextRefreshTime, + resetDescription: resetDescription) + + // Secondary: combined bonus/add-on credits (user + workspace) + var bonusDetail: String? + if self.bonusCreditsRemaining > 0, + let expiry = self.bonusNextExpiration, + self.bonusNextExpirationRemaining > 0 + { + let dateText = expiry.formatted(date: .abbreviated, time: .shortened) + bonusDetail = "\(self.bonusNextExpirationRemaining) credits expires on \(dateText)" + } + + let hasBonusWindow = self.bonusCreditsTotal > 0 + || self.bonusCreditsRemaining > 0 + || (bonusDetail?.isEmpty == false) + + let secondary: RateWindow? + if hasBonusWindow { + let bonusUsedPercent: Double = { + guard self.bonusCreditsTotal > 0 else { + return self.bonusCreditsRemaining > 0 ? 0 : 100 + } + let used = self.bonusCreditsTotal - self.bonusCreditsRemaining + return min(100, max(0, Double(used) / Double(self.bonusCreditsTotal) * 100)) + }() + secondary = RateWindow( + usedPercent: bonusUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: bonusDetail) + } else { + secondary = nil + } + + let identity = ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum WarpUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(Int, String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Warp API key." + case let .networkError(message): + "Warp network error: \(message)" + case let .apiError(code, message): + "Warp API error (\(code)): \(message)" + case let .parseFailed(message): + "Failed to parse Warp response: \(message)" + } + } +} + +public struct WarpUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.warpUsage) + private static let apiURL = URL(string: "https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo")! + private static let clientID = "warp-app" + /// Warp's GraphQL endpoint is fronted by an edge limiter that returns HTTP 429 ("Rate exceeded.") + /// unless the User-Agent matches the official client pattern (e.g. "Warp/1.0"). + private static let userAgent = "Warp/1.0" + + private static let graphQLQuery = """ + query GetRequestLimitInfo($requestContext: RequestContext!) { + user(requestContext: $requestContext) { + __typename + ... on UserOutput { + user { + requestLimitInfo { + isUnlimited + nextRefreshTime + requestLimit + requestsUsedSinceLastRefresh + } + bonusGrants { + requestCreditsGranted + requestCreditsRemaining + expiration + } + workspaces { + bonusGrantsInfo { + grants { + requestCreditsGranted + requestCreditsRemaining + expiration + } + } + } + } + } + } + } + """ + + public static func fetchUsage(apiKey: String) async throws -> WarpUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw WarpUsageError.missingCredentials + } + + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(self.clientID, forHTTPHeaderField: "x-warp-client-id") + request.setValue("macOS", forHTTPHeaderField: "x-warp-os-category") + request.setValue("macOS", forHTTPHeaderField: "x-warp-os-name") + request.setValue(osVersionString, forHTTPHeaderField: "x-warp-os-version") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + + let variables: [String: Any] = [ + "requestContext": [ + "clientContext": [:] as [String: Any], + "osContext": [ + "category": "macOS", + "name": "macOS", + "version": osVersionString, + ] as [String: Any], + ] as [String: Any], + ] + + let body: [String: Any] = [ + "query": self.graphQLQuery, + "variables": variables, + "operationName": "GetRequestLimitInfo", + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw WarpUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data) + Self.log.error("Warp API returned \(httpResponse.statusCode): \(summary)") + throw WarpUsageError.apiError(httpResponse.statusCode, summary) + } + + do { + let snapshot = try Self.parseResponse(data: data) + Self.log.debug( + "Warp usage parsed requestLimit=\(snapshot.requestLimit) requestsUsed=\(snapshot.requestsUsed) " + + "bonusRemaining=\(snapshot.bonusCreditsRemaining) bonusTotal=\(snapshot.bonusCreditsTotal) " + + "isUnlimited=\(snapshot.isUnlimited)") + return snapshot + } catch { + Self.log.error("Warp response parse failed bytes=\(data.count) error=\(error.localizedDescription)") + throw error + } + } + + static func _parseResponseForTesting(_ data: Data) throws -> WarpUsageSnapshot { + try self.parseResponse(data: data) + } + + static func _apiErrorSummaryForTesting(statusCode: Int, data: Data) -> String { + self.apiErrorSummary(statusCode: statusCode, data: data) + } + + private static func parseResponse(data: Data) throws -> WarpUsageSnapshot { + guard let root = try? JSONSerialization.jsonObject(with: data), + let json = root as? [String: Any] + else { + throw WarpUsageError.parseFailed("Root JSON is not an object.") + } + + if let rawErrors = json["errors"] as? [Any], !rawErrors.isEmpty { + let messages = rawErrors.compactMap(Self.graphQLErrorMessage(from:)) + let summary = messages.isEmpty ? "GraphQL request failed." : messages.prefix(3).joined(separator: " | ") + throw WarpUsageError.apiError(200, summary) + } + + guard let dataObj = json["data"] as? [String: Any], + let userObj = dataObj["user"] as? [String: Any] + else { + throw WarpUsageError.parseFailed("Missing data.user in response.") + } + + let typeName = (userObj["__typename"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let innerUserObj = userObj["user"] as? [String: Any], + let limitInfo = innerUserObj["requestLimitInfo"] as? [String: Any] + else { + if let typeName, !typeName.isEmpty, typeName != "UserOutput" { + throw WarpUsageError.parseFailed("Unexpected user type '\(typeName)'.") + } + throw WarpUsageError.parseFailed("Unable to extract requestLimitInfo from response.") + } + + let isUnlimited = Self.boolValue(limitInfo["isUnlimited"]) + let requestLimit = self.intValue(limitInfo["requestLimit"]) + let requestsUsed = self.intValue(limitInfo["requestsUsedSinceLastRefresh"]) + + var nextRefreshTime: Date? + if let nextRefreshTimeString = limitInfo["nextRefreshTime"] as? String { + nextRefreshTime = Self.parseDate(nextRefreshTimeString) + } + + // Parse and combine bonus credits from user-level and workspace-level + let bonus = Self.parseBonusCredits(from: innerUserObj) + + return WarpUsageSnapshot( + requestLimit: requestLimit, + requestsUsed: requestsUsed, + nextRefreshTime: nextRefreshTime, + isUnlimited: isUnlimited, + updatedAt: Date(), + bonusCreditsRemaining: bonus.remaining, + bonusCreditsTotal: bonus.total, + bonusNextExpiration: bonus.nextExpiration, + bonusNextExpirationRemaining: bonus.nextExpirationRemaining) + } + + private struct BonusGrant: Sendable { + let granted: Int + let remaining: Int + let expiration: Date? + } + + private struct BonusSummary: Sendable { + let remaining: Int + let total: Int + let nextExpiration: Date? + let nextExpirationRemaining: Int + } + + private static func parseBonusCredits(from userObj: [String: Any]) -> BonusSummary { + var grants: [BonusGrant] = [] + + // User-level bonus grants + if let bonusGrants = userObj["bonusGrants"] as? [[String: Any]] { + for grant in bonusGrants { + grants.append(Self.parseBonusGrant(from: grant)) + } + } + + // Workspace-level bonus grants + if let workspaces = userObj["workspaces"] as? [[String: Any]] { + for workspace in workspaces { + if let bonusGrantsInfo = workspace["bonusGrantsInfo"] as? [String: Any], + let workspaceGrants = bonusGrantsInfo["grants"] as? [[String: Any]] + { + for grant in workspaceGrants { + grants.append(Self.parseBonusGrant(from: grant)) + } + } + } + } + + let totalRemaining = grants.reduce(0) { $0 + $1.remaining } + let totalGranted = grants.reduce(0) { $0 + $1.granted } + + let expiring = grants.compactMap { grant -> (date: Date, remaining: Int)? in + guard grant.remaining > 0, let expiration = grant.expiration else { return nil } + return (expiration, grant.remaining) + } + + let nextExpiration: Date? + let nextExpirationRemaining: Int + if let earliest = expiring.min(by: { $0.date < $1.date }) { + let earliestKey = Int(earliest.date.timeIntervalSince1970) + let remaining = expiring.reduce(0) { result, item in + let key = Int(item.date.timeIntervalSince1970) + return result + (key == earliestKey ? item.remaining : 0) + } + nextExpiration = earliest.date + nextExpirationRemaining = remaining + } else { + nextExpiration = nil + nextExpirationRemaining = 0 + } + + return BonusSummary( + remaining: totalRemaining, + total: totalGranted, + nextExpiration: nextExpiration, + nextExpirationRemaining: nextExpirationRemaining) + } + + private static func parseBonusGrant(from grant: [String: Any]) -> BonusGrant { + let granted = self.intValue(grant["requestCreditsGranted"]) + let remaining = self.intValue(grant["requestCreditsRemaining"]) + let expiration = (grant["expiration"] as? String).flatMap(Self.parseDate) + return BonusGrant(granted: granted, remaining: remaining, expiration: expiration) + } + + private static func intValue(_ value: Any?) -> Int { + if let int = value as? Int { return int } + if let num = value as? NSNumber { return num.intValue } + if let text = value as? String, let int = Int(text) { return int } + return 0 + } + + private static func boolValue(_ value: Any?) -> Bool { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let text = value as? String { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["true", "1", "yes"].contains(normalized) { + return true + } + if ["false", "0", "no"].contains(normalized) { + return false + } + } + return false + } + + private static func graphQLErrorMessage(from value: Any) -> String? { + if let message = value as? String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if let dict = value as? [String: Any], + let message = dict["message"] as? String + { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return nil + } + + private static func apiErrorSummary(statusCode: Int, data: Data) -> String { + guard let root = try? JSONSerialization.jsonObject(with: data), + let json = root as? [String: Any] + else { + if let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + { + return self.compactSummaryText(text) + } + return "Unexpected response body (\(data.count) bytes)." + } + + if let rawErrors = json["errors"] as? [Any], !rawErrors.isEmpty { + let messages = rawErrors.compactMap(Self.graphQLErrorMessage(from:)) + let joined = messages.prefix(3).joined(separator: " | ") + if !joined.isEmpty { + return self.compactSummaryText(joined) + } + } + + if let error = json["error"] as? String { + let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return self.compactSummaryText(trimmed) + } + } + + if let message = json["message"] as? String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return self.compactSummaryText(trimmed) + } + } + + return "HTTP \(statusCode) (\(data.count) bytes)." + } + + private static func compactSummaryText(_ text: String, maxLength: Int = 200) -> String { + let collapsed = text + .components(separatedBy: .newlines) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if collapsed.count <= maxLength { + return collapsed + } + let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength) + return "\(collapsed[.. Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: dateString) { + return date + } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: dateString) + } +} diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 41dbf720d..1592a6181 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -22,9 +22,9 @@ public struct ZaiLimitEntry: Sendable { public let type: ZaiLimitType public let unit: ZaiLimitUnit public let number: Int - public let usage: Int - public let currentValue: Int - public let remaining: Int + public let usage: Int? + public let currentValue: Int? + public let remaining: Int? public let percentage: Double public let usageDetails: [ZaiUsageDetail] public let nextResetTime: Date? @@ -33,9 +33,9 @@ public struct ZaiLimitEntry: Sendable { type: ZaiLimitType, unit: ZaiLimitUnit, number: Int, - usage: Int, - currentValue: Int, - remaining: Int, + usage: Int?, + currentValue: Int?, + remaining: Int?, percentage: Double, usageDetails: [ZaiUsageDetail], nextResetTime: Date?) @@ -93,12 +93,23 @@ extension ZaiLimitEntry { } private var computedUsedPercent: Double? { - guard self.usage > 0 else { return nil } - let limit = max(0, self.usage) - guard limit > 0 else { return nil } + guard let limit = self.usage, limit > 0 else { return nil } + + // z.ai sometimes omits quota fields; don't invent zeros (can yield 100% used incorrectly). + var usedRaw: Int? + if let remaining = self.remaining { + let usedFromRemaining = limit - remaining + if let currentValue = self.currentValue { + usedRaw = max(usedFromRemaining, currentValue) + } else { + usedRaw = usedFromRemaining + } + } else if let currentValue = self.currentValue { + usedRaw = currentValue + } + guard let usedRaw else { return nil } - let usedFromRemaining = limit - self.remaining - let used = max(0, min(limit, max(usedFromRemaining, self.currentValue))) + let used = max(0, min(limit, usedRaw)) let percent = (Double(used) / Double(limit)) * 100 return min(100, max(0, percent)) } @@ -225,9 +236,9 @@ private struct ZaiLimitRaw: Codable { let type: String let unit: Int let number: Int - let usage: Int - let currentValue: Int - let remaining: Int + let usage: Int? + let currentValue: Int? + let remaining: Int? let percentage: Int let usageDetails: [ZaiUsageDetail]? let nextResetTime: Int? @@ -304,6 +315,14 @@ public struct ZaiUsageFetcher: Sendable { throw ZaiUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") } + // Some upstream issues (wrong endpoint/region/proxy) can yield HTTP 200 with an empty body. + // JSONDecoder will otherwise throw an opaque Cocoa error ("data is missing"). + guard !data.isEmpty else { + Self.log.error("z.ai API returned empty body (HTTP 200) for \(Self.safeURLForLogging(quotaURL))") + throw ZaiUsageError.parseFailed( + "Empty response body (HTTP 200). Check z.ai API region (Global vs BigModel CN) and your API token.") + } + // Log raw response for debugging if let jsonString = String(data: data, encoding: .utf8) { Self.log.debug("z.ai API response: \(jsonString)") @@ -322,7 +341,18 @@ public struct ZaiUsageFetcher: Sendable { } } + private static func safeURLForLogging(_ url: URL) -> String { + let host = url.host ?? "" + let port = url.port.map { ":\($0)" } ?? "" + let path = url.path.isEmpty ? "/" : url.path + return "\(host)\(port)\(path)" + } + static func parseUsageSnapshot(from data: Data) throws -> ZaiUsageSnapshot { + guard !data.isEmpty else { + throw ZaiUsageError.parseFailed("Empty response body") + } + let decoder = JSONDecoder() let apiResponse = try decoder.decode(ZaiQuotaLimitResponse.self, from: data) diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index b2bc65d41..2a1d0f1d4 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -51,5 +51,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .ollama: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Ollama Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..f2370e9ca 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -54,6 +54,7 @@ public struct UsageSnapshot: Codable, Sendable { public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let openRouterUsage: OpenRouterUsageSnapshot? public let cursorRequests: CursorRequestUsage? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -63,6 +64,7 @@ public struct UsageSnapshot: Codable, Sendable { case secondary case tertiary case providerCost + case openRouterUsage case updatedAt case identity case accountEmail @@ -77,6 +79,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + openRouterUsage: OpenRouterUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) @@ -87,6 +90,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.openRouterUsage = openRouterUsage self.cursorRequests = cursorRequests self.updatedAt = updatedAt self.identity = identity @@ -100,6 +104,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { @@ -127,6 +132,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) + try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) @@ -172,20 +178,26 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } - public func scoped(to provider: UsageProvider) -> UsageSnapshot { - guard let identity else { return self } - let scopedIdentity = identity.scoped(to: provider) - if scopedIdentity.providerID == identity.providerID { return self } - return UsageSnapshot( + /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. + public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { + UsageSnapshot( primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + openRouterUsage: self.openRouterUsage, cursorRequests: self.cursorRequests, updatedAt: self.updatedAt, - identity: scopedIdentity) + identity: identity) + } + + public func scoped(to provider: UsageProvider) -> UsageSnapshot { + guard let identity else { return self } + let scopedIdentity = identity.scoped(to: provider) + if scopedIdentity.providerID == identity.providerID { return self } + return self.withIdentity(scopedIdentity) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 37f87cf57..0e6557a66 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -54,6 +54,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-haiku-4-5": ClaudePricing( + inputCostPerToken: 1e-6, + outputCostPerToken: 5e-6, + cacheCreationInputCostPerToken: 1.25e-6, + cacheReadInputCostPerToken: 1e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-opus-4-5-20251101": ClaudePricing( inputCostPerToken: 5e-6, outputCostPerToken: 2.5e-5, @@ -64,6 +74,36 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-5": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-6-20260205": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-6": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index d47d7d557..f5cb75f5e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -57,48 +57,22 @@ enum CostUsageScanner { options: Options = Options()) -> CostUsageDailyReport { let range = CostUsageDayRange(since: since, until: until) + let emptyReport = CostUsageDailyReport(data: [], summary: nil) switch provider { case .codex: return self.loadCodexDaily(range: range, now: now, options: options) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) - case .zai: - return CostUsageDailyReport(data: [], summary: nil) - case .gemini: - return CostUsageDailyReport(data: [], summary: nil) - case .antigravity: - return CostUsageDailyReport(data: [], summary: nil) - case .cursor: - return CostUsageDailyReport(data: [], summary: nil) - case .opencode: - return CostUsageDailyReport(data: [], summary: nil) - case .factory: - return CostUsageDailyReport(data: [], summary: nil) - case .copilot: - return CostUsageDailyReport(data: [], summary: nil) - case .minimax: - return CostUsageDailyReport(data: [], summary: nil) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .kiro: - return CostUsageDailyReport(data: [], summary: nil) - case .kimi: - return CostUsageDailyReport(data: [], summary: nil) - case .kimik2: - return CostUsageDailyReport(data: [], summary: nil) - case .augment: - return CostUsageDailyReport(data: [], summary: nil) - case .jetbrains: - return CostUsageDailyReport(data: [], summary: nil) - case .amp: - return CostUsageDailyReport(data: [], summary: nil) - case .synthetic: - return CostUsageDailyReport(data: [], summary: nil) + case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, .kimi, .kimik2, + .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611ee..5b88abbdd 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -39,6 +39,7 @@ enum ProviderChoice: String, AppEnum { } } + // swiftlint:disable:next cyclomatic_complexity init?(provider: UsageProvider) { switch provider { case .codex: self = .codex @@ -58,7 +59,10 @@ enum ProviderChoice: String, AppEnum { case .kimi: return nil // Kimi not yet supported in widgets case .kimik2: return nil // Kimi K2 not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets + case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets + case .openrouter: return nil // OpenRouter not yet supported in widgets + case .warp: return nil // Warp not yet supported in widgets } } } @@ -212,7 +216,8 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] { let enabled = snapshot.enabledProviders let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled - return providers.isEmpty ? [.codex] : providers + let supported = providers.filter { ProviderChoice(provider: $0) != nil } + return supported.isEmpty ? [.codex] : supported } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b4506..7ad1064e5 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -274,7 +274,10 @@ private struct ProviderSwitchChip: View { case .kimi: "Kimi" case .kimik2: "Kimi K2" case .amp: "Amp" + case .ollama: "Ollama" case .synthetic: "Synthetic" + case .openrouter: "OpenRouter" + case .warp: "Warp" } } } @@ -567,6 +570,7 @@ private struct UsageHistoryChart: View { } enum WidgetColors { + // swiftlint:disable:next cyclomatic_complexity static func color(for provider: UsageProvider) -> Color { switch provider { case .codex: @@ -603,8 +607,14 @@ enum WidgetColors { Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red + case .ollama: + Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal + case .openrouter: + Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .warp: + Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) } } } diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index afbf81c9c..8a4e8506c 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -17,4 +17,31 @@ struct AmpUsageFetcherTests { #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } + + @Test + func detectsLoginRedirects() throws { + let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(signIn)) + + let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(sso)) + + let login = try #require(URL(string: "https://ampcode.com/login")) + #expect(AmpUsageFetcher.isLoginRedirect(login)) + + let signin = try #require(URL(string: "https://www.ampcode.com/signin")) + #expect(AmpUsageFetcher.isLoginRedirect(signin)) + } + + @Test + func ignoresNonLoginURLs() throws { + let settings = try #require(URL(string: "https://ampcode.com/settings")) + #expect(!AmpUsageFetcher.isLoginRedirect(settings)) + + let signOut = try #require(URL(string: "https://ampcode.com/auth/sign-out")) + #expect(!AmpUsageFetcher.isLoginRedirect(signOut)) + + let evil = try #require(URL(string: "https://ampcode.com.evil.com/auth/sign-in")) + #expect(!AmpUsageFetcher.isLoginRedirect(evil)) + } } diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index ec8551802..fe74c2860 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -20,6 +20,8 @@ struct CLIProviderSelectionTests { "|copilot|", "|synthetic|", "|kiro|", + "|warp|", + "|ollama|", "|both|", "|all]", ] diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index aaf237092..18cde0c98 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -65,6 +65,70 @@ struct CLISnapshotTests { #expect(!output.contains("Weekly:")) } + @Test + func rendersWarpUnlimitedAsDetailNotReset() { + let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata + let snap = UsageSnapshot( + primary: .init(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Unlimited"), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let output = CLIRenderer.renderText( + provider: .warp, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Warp 0.0.0 (warp)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("\(meta.sessionLabel): 100% left")) + #expect(!output.contains("Resets Unlimited")) + #expect(output.contains("Unlimited")) + } + + @Test + func rendersWarpCreditsAsDetailAndResetAsDate() { + let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata + let now = Date(timeIntervalSince1970: 0) + let snap = UsageSnapshot( + primary: .init( + usedPercent: 10, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "10/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let output = CLIRenderer.renderText( + provider: .warp, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Warp 0.0.0 (warp)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("\(meta.sessionLabel): 90% left")) + #expect(output.contains("Resets")) + #expect(output.contains("10/100 credits")) + #expect(!output.contains("Resets 10/100 credits")) + } + @Test func rendersPaceLineWhenWeeklyHasReset() { let now = Date() diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift index b3efe7cba..8ebc1cbb1 100644 --- a/Tests/CodexBarTests/CLIWebFallbackTests.swift +++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift @@ -4,22 +4,35 @@ import Testing @Suite struct CLIWebFallbackTests { - private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + private func makeContext( + runtime: ProviderRuntime = .cli, + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( - runtime: .cli, + runtime: runtime, sourceMode: sourceMode, includeCredits: true, webTimeout: 60, webDebugDumpHTML: false, verbose: false, env: [:], - settings: nil, + settings: settings, fetcher: UsageFetcher(), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection) } + private func makeClaudeSettingsSnapshot(cookieHeader: String?) -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot.make( + claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: cookieHeader)) + } + @Test func codexFallsBackWhenCookiesMissing() { let context = self.makeContext() @@ -49,4 +62,42 @@ struct CLIWebFallbackTests { on: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"), context: context)) } + + @Test + func claudeFallsBackWhenNoSessionKey() { + let context = self.makeContext() + let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.noSessionKeyFound, context: context)) + #expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.unauthorized, context: context)) + } + + @Test + func claudeCLIFallbackIsEnabledOnlyForAppAuto() { + let strategy = ClaudeCLIFetchStrategy( + useWebExtras: false, + manualCookieHeader: nil, + browserDetection: BrowserDetection(cacheTTL: 0)) + let error = ClaudeUsageError.parseFailed("cli failed") + let webAvailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "sessionKey=sk-ant-test") + let webUnavailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "foo=bar") + + #expect(strategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webAvailableSettings))) + #expect(!strategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webUnavailableSettings))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .cli))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .web))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .oauth))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto))) + } + + @Test + func claudeWebFallbackIsDisabledForAppAuto() { + let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0)) + let error = ClaudeWebAPIFetcher.FetchError.unauthorized + #expect(strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .auto))) + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift new file mode 100644 index 000000000..41ea049f7 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift @@ -0,0 +1,732 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStorePromptPolicyTests { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + @Test + func doesNotReadClaudeKeychainInBackgroundWhenPromptModeOnlyOnUserAction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + do { + _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: fingerprint) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + } + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + } + } + } + } + + @Test + func canReadClaudeKeychainOnUserActionWhenPromptModeOnlyOnUserAction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1") + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let creds = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: fingerprint) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + } + + #expect(creds.accessToken == "keychain-token") + } + } + } + } + + @Test + func doesNotShowPreAlertWhenClaudeKeychainReadableWithoutInteraction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .allowed + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + }) + }) + + #expect(creds.accessToken == "keychain-token") + #expect(preAlertHits == 0) + } + } + } + } + } + + @Test + func showsPreAlertWhenClaudeKeychainLikelyRequiresInteraction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + }) + }) + + #expect(creds.accessToken == "keychain-token") + // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped. + // This path can currently emit more than one pre-alert during a single load attempt. + #expect(preAlertHits >= 1) + } + } + } + } + } + + @Test + func showsPreAlertWhenClaudeKeychainPreflightFails() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let keychainData = self.makeCredentialsData( + accessToken: "keychain-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .failure(-1) + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + }) + }) + + #expect(creds.accessToken == "keychain-token") + // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped. + // This path can currently emit more than one pre-alert during a single load attempt. + #expect(preAlertHits >= 1) + } + } + } + } + } + + @Test + func experimentalReader_skipsPreAlertWhenSecurityCLIReadSucceeds() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + } + }) + }) + + #expect(creds.accessToken == "security-token") + #expect(preAlertHits == 0) + } + } + } + } + } + + @Test + func experimentalReader_showsPreAlertWhenSecurityCLIFailsAndFallbackNeedsInteraction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.timedOut) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + } + } + }) + }) + + #expect(creds.accessToken == "fallback-token") + #expect(preAlertHits >= 1) + } + } + } + } + } + + @Test + func experimentalReader_doesNotFallbackInBackgroundWhenStoredModeOnlyOnUserAction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + + do { + _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.timedOut) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: true) + } + } + } + } + } + }) + }) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + #expect(preAlertHits == 0) + } + } + } + } + } + + @Test + func experimentalReader_doesNotFallbackWhenStoredModeNever() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + + do { + _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.timedOut) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + } + } + } + } + } + }) + }) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + #expect(preAlertHits == 0) + } + } + } + } + } + + @Test + func experimentalReader_nonInteractiveFallbackBlockedInBackgroundWhenStoredModeOnlyOnUserAction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token-only-on-user-action", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .allowed + } + + do { + _ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.timedOut) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } + } + } + } + }) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + } + } + } + } + } + + @Test + func experimentalReader_allowsFallbackInBackgroundWhenStoredModeAlways() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + var preAlertHits = 0 + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .interactionRequired + } + let promptHandler: (KeychainPromptContext) -> Void = { _ in + preAlertHits += 1 + } + + let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.timedOut) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } + } + } + }) + }) + + #expect(creds.accessToken == "fallback-token") + #expect(preAlertHits >= 1) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift new file mode 100644 index 000000000..4db21b4b4 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests { + private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"] + } + } + """ + return Data(json.utf8) + } + + @Test + func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_backgroundFallbackBlockedByStoredPolicy() { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-should-be-blocked", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .nonZeroExit) + { + ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() + } + } + } + }) + }) + } + + #expect(hasCredentials == false) + } + + @Test + func experimentalReader_syncFromClaudeKeychainWithoutPrompt_backgroundFallbackBlockedByStoredPolicy() { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + defer { ClaudeOAuthCredentialsStore.invalidateCache() } + + let fallbackData = self.makeCredentialsData( + accessToken: "sync-fallback-should-be-blocked", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class Counter: @unchecked Sendable { + var value = 0 + } + let preflightCalls = Counter() + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + preflightCalls.value += 1 + return .allowed + } + + let synced = KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .timedOut) + { + ClaudeOAuthCredentialsStore + .syncFromClaudeKeychainWithoutPrompt(now: Date()) + } + } + } + }) + }) + }) + + #expect(synced == false) + #expect(preflightCalls.value == 0) + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift new file mode 100644 index 000000000..bfae65a0f --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -0,0 +1,864 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreSecurityCLITests { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + @Test + func experimentalReader_prefersSecurityCLIForNonInteractiveLoad() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(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") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "security-refresh") + + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + }) + }) + + #expect(creds.accessToken == "security-token") + #expect(creds.refreshToken == "security-refresh") + #expect(creds.scopes.contains("user:profile")) + } + } + } + } + + @Test + func experimentalReader_nonInteractiveBackgroundLoad_stillExecutesSecurityCLIRead() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(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") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-token-background", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "security-refresh-background") + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityReadCalls = ReadCounter() + + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + }) + }) + + #expect(creds.accessToken == "security-token-background") + #expect(securityReadCalls.count == 1) + } + } + } + } + + @Test + func experimentalReader_fallsBackWhenSecurityCLIThrows() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(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") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "fallback-refresh") + + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .timedOut) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + } + }) + }) + + #expect(creds.accessToken == "fallback-token") + } + } + } + } + + @Test + func experimentalReader_fallsBackWhenSecurityCLIOutputMalformed() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) + ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(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") + + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction, + operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(Data("not-json".utf8))) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + } + }) + }) + + #expect(creds.accessToken == "fallback-token") + } + } + } + } + + @Test + func experimentalReader_loadFromClaudeKeychainUsesSecurityCLI() throws { + let securityData = self.makeCredentialsData( + accessToken: "security-direct", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "security-refresh") + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 200, + createdAt: 199, + persistentRefHash: "sentinel") + + let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .always, + operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain() + } + } + } + } + }) + }) + + let creds = try ClaudeOAuthCredentials.parse(data: loaded) + #expect(creds.accessToken == "security-direct") + #expect(creds.refreshToken == "security-refresh") + #expect(fingerprintStore.fingerprint == nil) + } + + @Test + func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_usesSecurityCLI() { + let securityData = self.makeCredentialsData( + accessToken: "security-available", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .always, + operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() + } + } + }) + }) + + #expect(hasCredentials == true) + } + + @Test + func experimentalReader_hasClaudeKeychainCredentialsWithoutPrompt_fallsBackWhenSecurityCLIFails() { + let fallbackData = self.makeCredentialsData( + accessToken: "fallback-available", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .always, + operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .nonZeroExit) + { + ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() + } + } + } + }) + }) + + #expect(hasCredentials == true) + } + + @Test + func experimentalReader_ignoresPromptPolicyAndCooldownForBackgroundSilentCheck() { + let securityData = self.makeCredentialsData( + accessToken: "security-background", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) { + ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .never, + operation: { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() + } + } + }) + }) + } + } + + #expect(hasCredentials == true) + } + + @Test + func experimentalReader_loadFromClaudeKeychainFallbackBlockedWhenStoredModeNever() throws { + var threwNotFound = false + do { + _ = try KeychainAccessGate.withTaskOverrideForTesting(false) { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .never, + operation: { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .nonZeroExit) + { + try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain() + } + } + }) + }) + } + } catch let error as ClaudeOAuthCredentialsError { + if case .notFound = error { + threwNotFound = true + } + } + + #expect(threwNotFound == true) + } + + @Test + func experimentalReader_securityCLIRead_pinsPreferredAccountWhenAvailable() throws { + let securityData = self.makeCredentialsData( + accessToken: "security-account-pinned", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class AccountBox: @unchecked Sendable { + var value: String? + } + let pinnedAccount = AccountBox() + + let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .always, + operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { request in + pinnedAccount.value = request.account + return securityData + }) { + try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain() + } + } + }) + } + }) + + let creds = try ClaudeOAuthCredentials.parse(data: loaded) + #expect(pinnedAccount.value == "new-account") + #expect(creds.accessToken == "security-account-pinned") + } + + @Test + func experimentalReader_securityCLIRead_doesNotPinAccountInBackground() throws { + let securityData = self.makeCredentialsData( + accessToken: "security-account-not-pinned", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class AccountBox: @unchecked Sendable { + var value: String? + } + let pinnedAccount = AccountBox() + + let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .always, + operation: { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { request in + pinnedAccount.value = request.account + return securityData + }) { + try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain() + } + } + }) + } + }) + + let creds = try ClaudeOAuthCredentials.parse(data: loaded) + #expect(pinnedAccount.value == nil) + #expect(creds.accessToken == "security-account-not-pinned") + } + + @Test + func experimentalReader_freshnessSync_skipsSecurityCLIWhenPreflightRequiresInteraction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-sync", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityReadCalls = ReadCounter() + + func loadWithPreflight( + _ outcome: KeychainAccessPreflight.Outcome) throws -> ClaudeOAuthCredentials + { + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + outcome + } + return try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } + } + } + }) + } + + let first = try loadWithPreflight(.allowed) + #expect(first.accessToken == "security-sync") + #expect(securityReadCalls.count == 1) + + let second = try loadWithPreflight(.interactionRequired) + #expect(second.accessToken == "security-sync") + #expect(securityReadCalls.count == 1) + } + } + } + } + } + + @Test + func experimentalReader_freshnessSync_background_respectsStoredOnlyOnUserAction() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-sync-only-on-user-action", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityReadCalls = ReadCounter() + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in + .allowed + } + + func load(_ interaction: ProviderInteraction) throws -> ClaudeOAuthCredentials { + try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + preflightOverride, + operation: { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(interaction) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } + } + } + }) + } + + let first = try load(.userInitiated) + #expect(first.accessToken == "security-sync-only-on-user-action") + #expect(securityReadCalls.count == 1) + + let second = try load(.background) + #expect(second.accessToken == "security-sync-only-on-user-action") + #expect(securityReadCalls.count == 1) + } + } + } + } + } + + @Test + func experimentalReader_syncFromClaudeKeychainWithoutPrompt_skipsFingerprintProbeAfterSecurityCLIRead() { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + defer { ClaudeOAuthCredentialsStore.invalidateCache() } + + let securityData = self.makeCredentialsData( + accessToken: "security-sync-no-fingerprint-probe", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 123, + createdAt: 122, + persistentRefHash: "sentinel") + + let synced = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt( + now: Date()) + } + } + } + } + } + }) + + #expect(synced == true) + #expect(fingerprintStore.fingerprint == nil) + } + } + } + } + + @Test + func experimentalReader_noPromptRepair_skipsFingerprintProbeAfterSecurityCLISuccess() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-repair-no-fingerprint-probe", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 456, + createdAt: 455, + persistentRefHash: "sentinel") + + let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + } + } + } + } + }) + + #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe") + #expect(record.source == .claudeKeychain) + #expect(fingerprintStore.fingerprint == nil) + } + } + } + } + + @Test + func experimentalReader_loadWithPrompt_skipsFingerprintProbeAfterSecurityCLISuccess() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-load-with-prompt", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 321, + createdAt: 320, + persistentRefHash: "sentinel") + + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } + } + } + }) + + #expect(creds.accessToken == "security-load-with-prompt") + #expect(fingerprintStore.fingerprint == nil) + } + } + } + } + + @Test + func experimentalReader_loadWithPrompt_doesNotReadWhenGlobalKeychainDisabled() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-should-not-read", + expiresAt: Date(timeIntervalSinceNow: 3600)) + var threwNotFound = false + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityReadCalls = ReadCounter() + + do { + _ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityReadCalls.count += 1 + return securityData + }) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } + }) + } catch let error as ClaudeOAuthCredentialsError { + if case .notFound = error { + threwNotFound = true + } else { + throw error + } + } + + #expect(threwNotFound == true) + #expect(securityReadCalls.count < 1) + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index a5df002e0..5fd258eb0 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -27,40 +27,63 @@ struct ClaudeOAuthCredentialsStoreTests { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try ProviderInteractionContext.$current.withValue(.background) { try KeychainCacheStore.withServiceOverrideForTesting(service) { - try KeychainAccessGate.withTaskOverrideForTesting(true) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + 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) } + _ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityFramework) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + // Re-store to cache after file check has marked file as "seen" + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityFramework) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: 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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - 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) } - _ = 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) + } + } } } } @@ -69,50 +92,61 @@ struct ClaudeOAuthCredentialsStoreTests { @Test func loadRecord_nonInteractiveRepairCanBeDisabled() 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() } - // Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude). - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - ClaudeOAuthCredentialsStore.invalidateCache() + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + // Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude). + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore.invalidateCache() - let keychainData = self.makeCredentialsData( - accessToken: "claude-keychain", - expiresAt: Date(timeIntervalSinceNow: 3600)) - - // Simulate Claude Keychain containing creds, without querying the real Keychain. - try ClaudeOAuthCredentialsStore - .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) { - // When repair is disabled, non-interactive loads should not consult Claude's keychain data. - do { - _ = try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true, - allowClaudeKeychainRepairWithoutPrompt: false) - Issue.record("Expected ClaudeOAuthCredentialsError.notFound") - } catch let error as ClaudeOAuthCredentialsError { - guard case .notFound = error else { - Issue.record("Expected .notFound, got \(error)") - return + let keychainData = self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + // Simulate Claude Keychain containing creds, without querying the real Keychain. + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) { + // When repair is disabled, non-interactive loads should not consult Claude's + // keychain data. + do { + _ = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + // With repair enabled, we should be able to seed from the "Claude keychain" + // override. + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: true) + #expect(record.credentials.accessToken == "claude-keychain") + } } } - - // With repair enabled, we should be able to seed from the "Claude keychain" override. - let record = try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true, - allowClaudeKeychainRepairWithoutPrompt: true) - #expect(record.credentials.accessToken == "claude-keychain") } + } } } @@ -125,65 +159,68 @@ struct ClaudeOAuthCredentialsStoreTests { defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } // Avoid interacting with the real Keychain in unit tests. - 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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let first = self.makeCredentialsData( - accessToken: "first", - expiresAt: Date(timeIntervalSinceNow: 3600)) - try first.write(to: fileURL) + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let first = self.makeCredentialsData( + accessToken: "first", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try first.write(to: fileURL) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date()) - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date()) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) + _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) - let updated = self.makeCredentialsData( - accessToken: "second", - expiresAt: Date(timeIntervalSinceNow: 3600)) - try updated.write(to: fileURL) + let updated = self.makeCredentialsData( + accessToken: "second", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try updated.write(to: fileURL) - #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) - KeychainCacheStore.clear(key: cacheKey) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + KeychainCacheStore.clear(key: cacheKey) - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) - #expect(creds.accessToken == "second") + let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + #expect(creds.accessToken == "second") + } } } @Test func returnsExpiredFileWhenNoOtherSources() throws { - try KeychainAccessGate.withTaskOverrideForTesting(true) { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + 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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let expiredData = self.makeCredentialsData( - accessToken: "expired-only", - expiresAt: Date(timeIntervalSinceNow: -3600)) - try expiredData.write(to: fileURL) + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - 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") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let expiredData = self.makeCredentialsData( + accessToken: "expired-only", + expiresAt: Date(timeIntervalSinceNow: -3600)) + try expiredData.write(to: fileURL) - ClaudeOAuthCredentialsStore.invalidateCache() - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + 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) + } + } + } } } } @@ -201,37 +238,36 @@ struct ClaudeOAuthCredentialsStoreTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } - ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - defer { KeychainCacheStore.clear(key: cacheKey) } + let expiredData = self.makeCredentialsData( + accessToken: "expired-claude-cli-owner", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI)) - let expiredData = self.makeCredentialsData( - accessToken: "expired-claude-cli-owner", - expiresAt: Date(timeIntervalSinceNow: -3600), - refreshToken: "refresh-token") - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry( - data: expiredData, - storedAt: Date(), - owner: .claudeCLI)) - - do { - _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - Issue.record("Expected delegated refresh error for Claude CLI-owned credentials") - } catch let error as ClaudeOAuthCredentialsError { - guard case .refreshDelegatedToClaudeCLI = error else { - Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") - return + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected delegated refresh error for Claude CLI-owned credentials") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") } - } catch { - Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") } } } @@ -251,38 +287,37 @@ struct ClaudeOAuthCredentialsStoreTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } - - ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - defer { KeychainCacheStore.clear(key: cacheKey) } + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } - let expiredData = self.makeCredentialsData( - accessToken: "expired-codexbar-owner", - expiresAt: Date(timeIntervalSinceNow: -3600), - refreshToken: "refresh-token") - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry( - data: expiredData, - storedAt: Date(), - owner: .codexbar)) - - await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { - do { - _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - Issue.record("Expected refresh failure for CodexBar-owned direct refresh path") - } catch let error as ClaudeOAuthCredentialsError { - guard case .refreshFailed = error else { - Issue.record("Expected .refreshFailed, got \(error)") - return + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-owner", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected refresh failure for CodexBar-owned direct refresh path") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshFailed = error else { + Issue.record("Expected .refreshFailed, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") } - } catch { - Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") } } } @@ -302,29 +337,28 @@ struct ClaudeOAuthCredentialsStoreTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } - ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - defer { KeychainCacheStore.clear(key: cacheKey) } - - let validData = self.makeCredentialsData( - accessToken: "legacy-owner", - expiresAt: Date(timeIntervalSinceNow: 3600), - refreshToken: "refresh-token") - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry( - data: validData, - storedAt: Date())) - - let record = try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - #expect(record.owner == .claudeCLI) - #expect(record.source == .cacheKeychain) + let validData = self.makeCredentialsData( + accessToken: "legacy-owner", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: validData, + storedAt: Date())) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } } } @@ -785,148 +819,28 @@ struct ClaudeOAuthCredentialsStoreTests { } } - @Test - func doesNotShowPreAlertWhenClaudeKeychainReadableWithoutInteraction() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - KeychainPromptHandler.handler = nil - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in - .allowed - } - - var preAlertHits = 0 - KeychainPromptHandler.handler = { _ in - preAlertHits += 1 - } - - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true) - #expect(creds.accessToken == "keychain-token") - #expect(preAlertHits == 0) - } - } - - @Test - func showsPreAlertWhenClaudeKeychainLikelyRequiresInteraction() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - KeychainPromptHandler.handler = nil - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in - .interactionRequired - } - - var preAlertHits = 0 - KeychainPromptHandler.handler = { _ in - preAlertHits += 1 - } - - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true) - #expect(creds.accessToken == "keychain-token") - #expect(preAlertHits == 1) - } - } - - @Test - func showsPreAlertWhenClaudeKeychainPreflightFails() throws { - KeychainCacheStore.setTestStoreForTesting(true) - defer { KeychainCacheStore.setTestStoreForTesting(false) } - - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - defer { - ClaudeOAuthCredentialsStore.invalidateCache() - ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - KeychainPromptHandler.handler = nil - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting(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") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let keychainData = self.makeCredentialsData( - accessToken: "keychain-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(keychainData) - - KeychainAccessPreflight.setCheckGenericPasswordOverrideForTesting { _, _ in - .failure(-1) - } - - var preAlertHits = 0 - KeychainPromptHandler.handler = { _ in - preAlertHits += 1 - } - - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true) - #expect(creds.accessToken == "keychain-token") - #expect(preAlertHits == 1) - } - } - @Test func syncFromClaudeKeychainWithoutPrompt_respectsBackoffInBackground() { ProviderInteractionContext.$current.withValue(.background) { KeychainAccessGate.withTaskOverrideForTesting(true) { - ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) - defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } - - let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( - data: self.makeCredentialsData( - accessToken: "override-token", - expiresAt: Date(timeIntervalSinceNow: 3600)), - fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "deadbeefdead")) - - let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() - deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600) - - ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) { - ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) { - #expect(ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) == false) + ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( + data: self.makeCredentialsData( + accessToken: "override-token", + expiresAt: Date(timeIntervalSinceNow: 3600)), + fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "deadbeefdead")) + + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600) + + ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) { + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) { + #expect(ClaudeOAuthCredentialsStore + .syncFromClaudeKeychainWithoutPrompt(now: Date()) == false) + } } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 0f6b7ab4a..3ab8f87c9 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -15,6 +15,20 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } } + private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"] + } + } + """ + return Data(json.utf8) + } + @Test func cooldownPreventsRepeatedAttempts() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -210,4 +224,226 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(outcomes.allSatisfy { $0 == .attemptedSucceeded }) #expect(counter.count == 1) } + + @Test + func experimentalStrategy_doesNotUseSecurityFrameworkFingerprintObservation() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + } + let fingerprintCounter = CounterBox() + ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "framework-fingerprint") + } + ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) + ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in } + + let securityData = self.makeCredentialsData( + accessToken: "security-token-a", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.data(securityData)) + defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } + let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 60000), + timeout: 0.1) + + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome") + return + } + #expect(fingerprintCounter.count < 1) + } + } + + @Test + func experimentalStrategy_observesSecurityCLIChangeAfterTouch() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var _data: Data? + init(data: Data?) { + self._data = data + } + + func load() -> Data? { + self.lock.lock() + defer { self.lock.unlock() } + return self._data + } + + func store(_ data: Data?) { + self.lock.lock() + self._data = data + self.lock.unlock() + } + } + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + } + let fingerprintCounter = CounterBox() + ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 11, + createdAt: 11, + persistentRefHash: "framework-fingerprint") + } + + let beforeData = self.makeCredentialsData( + accessToken: "security-token-before", + expiresAt: Date(timeIntervalSinceNow: -60)) + let afterData = self.makeCredentialsData( + accessToken: "security-token-after", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let dataBox = DataBox(data: beforeData) + + ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) + ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in + dataBox.store(afterData) + } + ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) + defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } + + let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + + #expect(outcome == .attemptedSucceeded) + #expect(fingerprintCounter.count < 1) + } + } + + @Test + func experimentalStrategy_missingBaselineDoesNotAutoSucceedWhenLaterReadSucceeds() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var _data: Data? + init(data: Data?) { + self._data = data + } + + func load() -> Data? { + self.lock.lock() + defer { self.lock.unlock() } + return self._data + } + + func store(_ data: Data?) { + self.lock.lock() + self._data = data + self.lock.unlock() + } + } + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + } + let fingerprintCounter = CounterBox() + ClaudeOAuthDelegatedRefreshCoordinator.setKeychainFingerprintOverrideForTesting { + fingerprintCounter.increment() + return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 21, + createdAt: 21, + persistentRefHash: "framework-fingerprint") + } + + let afterData = self.makeCredentialsData( + accessToken: "security-token-after-baseline-miss", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let dataBox = DataBox(data: nil) + + ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) + ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in + dataBox.store(afterData) + } + ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in dataBox.load() }) + defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } + + let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61500), + timeout: 0.1) + + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome when baseline is unavailable") + return + } + #expect(fingerprintCounter.count < 1) + } + } + + @Test + func experimentalStrategy_observationSkipsSecurityCLIWhenGlobalKeychainDisabled() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class CounterBox: @unchecked Sendable { + private let lock = NSLock() + private(set) var count: Int = 0 + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + } + + let securityReadCounter = CounterBox() + let securityData = self.makeCredentialsData( + accessToken: "security-should-not-be-read", + expiresAt: Date(timeIntervalSinceNow: 3600)) + ClaudeOAuthDelegatedRefreshCoordinator.setCLIAvailableOverrideForTesting(true) + ClaudeOAuthDelegatedRefreshCoordinator.setTouchAuthPathOverrideForTesting { _ in } + ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(.dynamic { _ in + securityReadCounter.increment() + return securityData + }) + defer { ClaudeOAuthCredentialsStore.setSecurityCLIReadOverrideForTesting(nil) } + let outcome = await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 62000), + timeout: 0.1) + } + + guard case .attemptedFailed = outcome else { + Issue.record("Expected .attemptedFailed outcome") + return + } + #expect(securityReadCounter.count < 1) + } + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift index d8b3d3a4f..fa25a78e6 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift @@ -74,77 +74,88 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - let snapshot = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - // Seed an expired cache entry owned by Claude CLI, so the initial load delegates refresh. - ClaudeOAuthCredentialsStore.invalidateCache() - let expiredData = self.makeCredentialsData( - accessToken: "expired-token", - expiresAt: Date(timeIntervalSinceNow: -3600)) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry( - data: expiredData, - storedAt: Date(), - owner: .claudeCLI) - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - defer { KeychainCacheStore.clear(key: cacheKey) } - - // Sanity: setup should be visible to the code under test. - // Otherwise it may attempt interactive reads. - #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true) - - // Simulate Claude CLI writing fresh credentials into the Claude Code keychain entry. - let freshData = self.makeCredentialsData( - accessToken: "fresh-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "test") - - let fetcher = ClaudeUsageFetcher( - browserDetection: BrowserDetection(cacheTTL: 0), - environment: [:], - dataSource: .oauth, - oauthKeychainPromptCooldownEnabled: true) - - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in - await tokenCapture.set(token) - return usageResponse - } - let delegatedOverride: (@Sendable ( - Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in - _ = await delegatedCounter.increment() - return .attemptedSucceeded - } + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + let snapshot = try await ClaudeOAuthCredentialsStore + .withCredentialsURLOverrideForTesting(fileURL) { + // Seed an expired cache entry owned by Claude CLI, so the initial load delegates + // refresh. + ClaudeOAuthCredentialsStore.invalidateCache() + let expiredData = self.makeCredentialsData( + accessToken: "expired-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + defer { KeychainCacheStore.clear(key: cacheKey) } + + // Sanity: setup should be visible to the code under test. + // Otherwise it may attempt interactive reads. + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true) + + // Simulate Claude CLI writing fresh credentials into the Claude Code keychain entry. + let freshData = self.makeCredentialsData( + accessToken: "fresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "test") + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in + await tokenCapture.set(token) + return usageResponse + } + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } - let snapshot = try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: freshData, - fingerprint: fingerprint) - { - try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride - .withValue(delegatedOverride) { - try await fetcher.loadLatestUsage(model: "sonnet") + let snapshot = try await ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: freshData, + fingerprint: fingerprint) + { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride + .withValue(fetchOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride + .withValue(delegatedOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } } - } - } - // If Claude keychain already contains fresh credentials, we should recover without needing a - // CLI - // touch. - #expect(await delegatedCounter.current() == 0) - #expect(await tokenCapture.get() == "fresh-token") - #expect(snapshot.primary.usedPercent == 7) - #expect(snapshot.secondary?.usedPercent == 21) - return snapshot + // If Claude keychain already contains fresh credentials, we should recover without + // needing a + // CLI + // touch. + #expect(await delegatedCounter.current() == 0) + #expect(await tokenCapture.get() == "fresh-token") + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.secondary?.usedPercent == 21) + return snapshot + } + _ = snapshot } - _ = snapshot } } } @@ -167,84 +178,195 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - let snapshot = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - // Seed an expired cache entry owned by Claude CLI, so the initial load delegates refresh. - ClaudeOAuthCredentialsStore.invalidateCache() - let expiredData = self.makeCredentialsData( - accessToken: "expired-token", - expiresAt: Date(timeIntervalSinceNow: -3600)) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry( - data: expiredData, - storedAt: Date(), - owner: .claudeCLI) - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - defer { KeychainCacheStore.clear(key: cacheKey) } - - // Ensure we don't silently repair from the Claude keychain before delegation. - // Use an explicit empty-data override so we never consult the real system Keychain during - // tests. - let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "test") - let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( - data: Data(), - fingerprint: stubFingerprint) - - let freshData = self.makeCredentialsData( - accessToken: "fresh-token", - expiresAt: Date(timeIntervalSinceNow: 3600)) - - let fetcher = ClaudeUsageFetcher( - browserDetection: BrowserDetection(cacheTTL: 0), - environment: [:], - dataSource: .oauth, - oauthKeychainPromptCooldownEnabled: true) - - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in - await tokenCapture.set(token) - return usageResponse - } + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + let snapshot = try await ClaudeOAuthCredentialsStore + .withCredentialsURLOverrideForTesting(fileURL) { + // Seed an expired cache entry owned by Claude CLI, so the initial load delegates + // refresh. + ClaudeOAuthCredentialsStore.invalidateCache() + let expiredData = self.makeCredentialsData( + accessToken: "expired-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + defer { KeychainCacheStore.clear(key: cacheKey) } - let delegatedOverride: (@Sendable ( - Date, - TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in - // Simulate Claude CLI writing fresh credentials after the delegated refresh touch. - keychainOverrideStore.data = freshData - keychainOverrideStore.fingerprint = stubFingerprint - _ = await delegatedCounter.increment() - return .attemptedSucceeded - } + // Ensure we don't silently repair from the Claude keychain before delegation. + // Use an explicit empty-data override so we never consult the real system Keychain + // during + // tests. + let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "test") + let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( + data: Data(), + fingerprint: stubFingerprint) - let snapshot = try await ClaudeOAuthCredentialsStore - .withMutableClaudeKeychainOverrideStoreForTesting( - keychainOverrideStore) - { - try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride - .withValue(delegatedOverride) { - try await fetcher.loadLatestUsage(model: "sonnet") + let freshData = self.makeCredentialsData( + accessToken: "fresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in + await tokenCapture.set(token) + return usageResponse + } + + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + // Simulate Claude CLI writing fresh credentials after the delegated refresh touch. + keychainOverrideStore.data = freshData + keychainOverrideStore.fingerprint = stubFingerprint + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + + let snapshot = try await ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(.always) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore + .withMutableClaudeKeychainOverrideStoreForTesting( + keychainOverrideStore) + { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride + .withValue(fetchOverride) { + try await ClaudeUsageFetcher + .$delegatedRefreshAttemptOverride + .withValue(delegatedOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } } + } + + #expect(await delegatedCounter.current() == 1) + let capturedToken = await tokenCapture.get() + if capturedToken != "fresh-token" { + Issue.record("Expected fresh-token, got \(capturedToken ?? "nil")") + } + #expect(capturedToken == "fresh-token") + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.secondary?.usedPercent == 21) + return snapshot + } + _ = snapshot + } + } + } + } + } + + @Test + func delegatedRefresh_attemptedSucceeded_backgroundOnlyOnUserAction_doesNotRecoverFromKeychain() async throws { + let delegatedCounter = AsyncCounter() + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } + + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore.invalidateCache() + let expiredData = self.makeCredentialsData( + accessToken: "expired-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + defer { KeychainCacheStore.clear(key: cacheKey) } + + // Expired Claude-CLI-owned credentials are still considered cache-present (delegatable). + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true) + + let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "test") + let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( + data: Data(), + fingerprint: stubFingerprint) + let freshData = self.makeCredentialsData( + accessToken: "fresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: false, + allowBackgroundDelegatedRefresh: true) + + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + keychainOverrideStore.data = freshData + keychainOverrideStore.fingerprint = stubFingerprint + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + + do { + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthCredentialsStore + .withMutableClaudeKeychainOverrideStoreForTesting(keychainOverrideStore) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride + .withValue(delegatedOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + Issue.record( + "Expected OAuth fetch failure: background keychain recovery should stay blocked") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return } + #expect(message.contains("still unavailable after delegated Claude CLI refresh")) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") } - #expect(await delegatedCounter.current() == 1) - let capturedToken = await tokenCapture.get() - if capturedToken != "fresh-token" { - Issue.record("Expected fresh-token, got \(capturedToken ?? "nil")") + #expect(await delegatedCounter.current() == 1) } - #expect(capturedToken == "fresh-token") - #expect(snapshot.primary.usedPercent == 7) - #expect(snapshot.secondary?.usedPercent == 21) - return snapshot } - _ = snapshot } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 31db80875..6bebf67a4 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -4,7 +4,21 @@ import Testing #if os(macOS) @Suite(.serialized) -struct ClaudeKeychainCLIFetchStrategyTests { +struct ClaudeOAuthFetchStrategyAvailabilityTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + private func makeContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext { let env: [String: String] = [:] return ProviderFetchContext( @@ -17,25 +31,268 @@ struct ClaudeKeychainCLIFetchStrategyTests { env: env, settings: nil, fetcher: UsageFetcher(environment: env), - claudeFetcher: ClaudeUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)), + claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) } + private func expiredRecord(owner: ClaudeOAuthCredentialOwner = .claudeCLI) -> ClaudeOAuthCredentialRecord { + ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "expired-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: -60), + scopes: ["user:profile"], + rateLimitTier: nil), + owner: owner, + source: .cacheKeychain) + } + + @Test + func autoModeExpiredCreds_cliAvailable_returnsAvailable() async { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await strategy.isAvailable(context) + } + } + #expect(available == true) + } + @Test - func keychainCLIStrategyAlwaysAvailable() async { + func autoModeExpiredCreds_cliUnavailable_returnsUnavailable() async { let context = self.makeContext(sourceMode: .auto) - let strategy = ClaudeKeychainCLIFetchStrategy() - let available = await strategy.isAvailable(context) + let strategy = ClaudeOAuthFetchStrategy() + let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) { + await strategy.isAvailable(context) + } + } + #expect(available == false) + } + + @Test + func oauthModeExpiredCreds_cliAvailable_returnsAvailable() async { + let context = self.makeContext(sourceMode: .oauth) + let strategy = ClaudeOAuthFetchStrategy() + let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await strategy.isAvailable(context) + } + } #expect(available == true) } @Test - func keychainCLIStrategyNeverFallsBack() { + func autoModeExpiredCodexbarCreds_cliUnavailable_stillAvailable() async { let context = self.makeContext(sourceMode: .auto) - let strategy = ClaudeKeychainCLIFetchStrategy() + let strategy = ClaudeOAuthFetchStrategy() + let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord(owner: .codexbar)) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) { + await strategy.isAvailable(context) + } + } + #expect(available == true) + } + + @Test + func oauthModeDoesNotFallbackAfterOAuthFailure() { + let context = self.makeContext(sourceMode: .oauth) + let strategy = ClaudeOAuthFetchStrategy() #expect(strategy.shouldFallback( - on: ClaudeUsageError.oauthFailed("test"), + on: ClaudeUsageError.oauthFailed("oauth failed"), context: context) == false) } + + @Test + func autoModeFallsBackAfterOAuthFailure() { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + #expect(strategy.shouldFallback( + on: ClaudeUsageError.oauthFailed("oauth failed"), + context: context) == true) + } + + @Test + func autoMode_userInitiated_clearsKeychainCooldownGate() async { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "expired-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: -60), + scopes: ["user:inference"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .cacheKeychain) + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1000) + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false) + + _ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(recordWithoutRequiredScope) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await strategy.isAvailable(context) + } + } + + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)) + } + } + + @Test + func autoMode_onlyOnUserAction_background_startup_withoutCache_isAvailableForBootstrap() async throws { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { + ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + ClaudeOAuthKeychainAccessGate.resetForTesting() + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + + let available = await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } + } + } + } + } + + #expect(available == true) + } + } + } + + @Test + func autoMode_experimental_reader_ignoresPromptPolicyCooldownGate() async { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + let securityData = Data(""" + { + "claudeAiOauth": { + "accessToken": "security-token", + "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + } + """.utf8) + + let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "token-no-scope", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: -60), + scopes: ["user:inference"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .cacheKeychain) + + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue( + recordWithoutRequiredScope) + { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data( + securityData)) + { + await strategy.isAvailable(context) + } + } + } + } + } + } + } + + #expect(available == true) + } + + @Test + func autoMode_experimental_reader_securityFailure_blocksAvailabilityWhenStoredPolicyBlocksFallback() async { + let context = self.makeContext(sourceMode: .auto) + let strategy = ClaudeOAuthFetchStrategy() + let fallbackData = Data(""" + { + "claudeAiOauth": { + "accessToken": "fallback-token", + "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + } + """.utf8) + + let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "token-no-scope", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: -60), + scopes: ["user:inference"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .cacheKeychain) + + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue( + recordWithoutRequiredScope) + { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: fallbackData, + fingerprint: nil) + { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .nonZeroExit) + { + await strategy.isAvailable(context) + } + } + } + } + } + } + } + } + + #expect(available == false) + } } #endif diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index e40052306..3854b7b12 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -7,17 +7,18 @@ struct ClaudeOAuthKeychainAccessGateTests { @Test func blocksUntilCooldownExpires() { KeychainAccessGate.withTaskOverrideForTesting(false) { - ClaudeOAuthKeychainAccessGate.resetForTesting() - defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } - - let now = Date(timeIntervalSince1970: 1000) - #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)) + let store = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(store) { + 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))) + 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))) + } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 2a7660bf5..478dcc6ef 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -244,26 +244,40 @@ struct ClaudeOAuthTests { selectedDataSource: .auto, webExtrasEnabled: false, hasWebSession: true, + hasCLI: true, hasOAuthCredentials: true) #expect(strategy.dataSource == .oauth) } @Test - func fallsBackToWebWhenOAuthMissing() { + func fallsBackToCLIWhenOAuthMissingAndCLIAvailable() { let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: .auto, webExtrasEnabled: false, hasWebSession: true, + hasCLI: true, + hasOAuthCredentials: false) + #expect(strategy.dataSource == .cli) + } + + @Test + func fallsBackToWebWhenOAuthMissingAndCLIMissing() { + let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( + selectedDataSource: .auto, + webExtrasEnabled: false, + hasWebSession: true, + hasCLI: false, hasOAuthCredentials: false) #expect(strategy.dataSource == .web) } @Test - func fallsBackToCLIWhenNoOAuthOrWeb() { + func fallsBackToCLIWhenOAuthMissingAndWebMissing() { let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: .auto, webExtrasEnabled: false, hasWebSession: false, + hasCLI: true, hasOAuthCredentials: false) #expect(strategy.dataSource == .cli) } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index ddf6d715f..7f007b811 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -80,13 +80,20 @@ struct ClaudeUsageTests { rateLimitTier: nil) } - let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: { - try await fetcher.loadLatestUsage(model: "sonnet") + let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride + .withValue(loadCredsOverride, operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) }) - }) - }) + } + } #expect(await loadCounter.current() == 2) #expect(await delegatedCounter.current() == 1) @@ -119,11 +126,19 @@ struct ClaudeUsageTests { throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI } - _ = try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: { - try await fetcher.loadLatestUsage(model: "sonnet") - }) - }) + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) + } + } Issue.record("Expected delegated retry to fail when credentials remain expired") } catch let error as ClaudeUsageError { guard case let .oauthFailed(message) = error else { @@ -164,11 +179,19 @@ struct ClaudeUsageTests { } do { - _ = try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: { - try await fetcher.loadLatestUsage(model: "sonnet") - }) - }) + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) + } + } Issue.record("Expected delegated retry to fail fast when CLI is unavailable") } catch let error as ClaudeUsageError { guard case let .oauthFailed(message) = error else { @@ -225,21 +248,237 @@ struct ClaudeUsageTests { rateLimitTier: nil) } - let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride, operation: { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride, operation: { - try await fetcher.loadLatestUsage(model: "sonnet") + let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride + .withValue(loadCredsOverride, operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) }) - }) - }) + } + } #expect(await loadCounter.current() == 2) #expect(await delegatedCounter.current() == 1) #expect(snapshot.primary.usedPercent == 7) - // Second call in Auto-mode must keep Keychain non-interactive (allowKeychainPrompt=false). + // User-initiated repair: if the delegated refresh couldn't sync silently, we may allow an interactive prompt + // on the retry to help recovery. #expect(flags.allowKeychainPromptFlags.count == 2) - #expect(flags.allowKeychainPromptFlags[1] == false) + #expect(flags.allowKeychainPromptFlags[1] == true) + } + + @Test + func oauthDelegatedRetry_onlyOnUserAction_background_suppressesDelegation() async throws { + let loadCounter = AsyncCounter() + let delegatedCounter = AsyncCounter() + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + _ = await loadCounter.increment() + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + + do { + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + Issue.record("Expected delegated refresh to be suppressed in background") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return + } + #expect(message.contains("background repair is suppressed")) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") + } + + #expect(await loadCounter.current() == 1) + #expect(await delegatedCounter.current() == 0) + } + + @Test + func oauthDelegatedRetry_never_background_suppressesDelegationEvenForCLI() async throws { + let loadCounter = AsyncCounter() + let delegatedCounter = AsyncCounter() + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true, + allowBackgroundDelegatedRefresh: true) + + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + _ = await loadCounter.increment() + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + + do { + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + Issue.record("Expected delegated refresh to be suppressed for prompt policy 'never'") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return + } + #expect(message.contains("Delegated refresh is disabled by 'never' keychain policy")) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") + } + + #expect(await loadCounter.current() == 1) + #expect(await delegatedCounter.current() == 0) + } + + @Test + func oauthBootstrap_onlyOnUserAction_background_startup_allowsInteractiveReadWhenNoCache() async throws { + final class FlagBox: @unchecked Sendable { + var allowKeychainPromptFlags: [Bool] = [] + } + + let flags = FlagBox() + let usageResponse = try Self.makeOAuthUsageResponse() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true, + allowStartupBootstrapPrompt: true) + + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in + flags.allowKeychainPromptFlags.append(allowKeychainPrompt) + return ClaudeOAuthCredentials( + accessToken: "fresh-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + + let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderRefreshContext.$current.withValue(.startup) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(false) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + } + } + + #expect(flags.allowKeychainPromptFlags == [true]) + #expect(snapshot.primary.usedPercent == 7) + } + + @Test + func oauthDelegatedRetry_onlyOnUserAction_background_allowsDelegationForCLI() async throws { + let loadCounter = AsyncCounter() + let delegatedCounter = AsyncCounter() + let usageResponse = try Self.makeOAuthUsageResponse() + + final class FlagBox: @unchecked Sendable { + var allowKeychainPromptFlags: [Bool] = [] + } + let flags = FlagBox() + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: false, + allowBackgroundDelegatedRefresh: true) + + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in + flags.allowKeychainPromptFlags.append(allowKeychainPrompt) + let call = await loadCounter.increment() + if call == 1 { + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + return ClaudeOAuthCredentials( + accessToken: "fresh-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + + let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + } + + #expect(await loadCounter.current() == 2) + #expect(await delegatedCounter.current() == 1) + #expect(snapshot.primary.usedPercent == 7) + #expect(flags.allowKeychainPromptFlags.allSatisfy { !$0 }) } @Test @@ -616,3 +855,105 @@ struct ClaudeUsageTests { #expect(cliVersion?.isEmpty != true) } } + +extension ClaudeUsageTests { + @Test + func oauthDelegatedRetry_experimental_background_ignoresOnlyOnUserActionSuppression() async throws { + let loadCounter = AsyncCounter() + let delegatedCounter = AsyncCounter() + let usageResponse = try Self.makeOAuthUsageResponse() + + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true, + allowBackgroundDelegatedRefresh: false) + + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let delegatedOverride: (@Sendable ( + Date, + TimeInterval) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _ in + _ = await delegatedCounter.increment() + return .attemptedSucceeded + } + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + let call = await loadCounter.increment() + if call == 1 { + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + return ClaudeOAuthCredentials( + accessToken: "fresh-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + + let snapshot = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride) + { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride) + { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + } + } + }) + + #expect(await loadCounter.current() == 2) + #expect(await delegatedCounter.current() == 1) + #expect(snapshot.primary.usedPercent == 7) + } + + @Test + func oauthLoad_experimental_background_fallbackBlocked_propagatesOAuthFailure() async throws { + final class FlagBox: @unchecked Sendable { + var respectPromptCooldownFlags: [Bool] = [] + } + let flags = FlagBox() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true, + allowBackgroundDelegatedRefresh: false) + + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, respectKeychainPromptCooldown in + flags.respectPromptCooldownFlags.append(respectKeychainPromptCooldown) + throw ClaudeOAuthCredentialsError.notFound + } + + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + }) + } + #expect(flags.respectPromptCooldownFlags == [true]) + } +} diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 4b1fecfe5..737081635 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -115,6 +115,59 @@ struct CodexBarTests { #expect(internalHoles >= 16) // at least one 4×4 eye block, but typically two eyes => 32 } + @Test + func iconRendererWarpEyesCutOutAtExpectedCenters() { + // Regression: Warp eyes should be tilted in-place and remain centered on the face. + let image = IconRenderer.makeIcon( + primaryRemaining: 50, + weeklyRemaining: 50, + creditsRemaining: nil, + stale: false, + style: .warp) + + let bitmapReps = image.representations.compactMap { $0 as? NSBitmapImageRep } + let rep = bitmapReps.first(where: { $0.pixelsWide == 36 && $0.pixelsHigh == 36 }) + #expect(rep != nil) + guard let rep else { return } + + func alphaAt(px x: Int, _ y: Int) -> CGFloat { + (rep.colorAt(x: x, y: y) ?? .clear).alphaComponent + } + + func minAlphaNear(px cx: Int, _ cy: Int, radius: Int) -> CGFloat { + var minAlpha: CGFloat = 1.0 + let x0 = max(0, cx - radius) + let x1 = min(rep.pixelsWide - 1, cx + radius) + let y0 = max(0, cy - radius) + let y1 = min(rep.pixelsHigh - 1, cy + radius) + for y in y0...y1 { + for x in x0...x1 { + minAlpha = min(minAlpha, alphaAt(px: x, y)) + } + } + return minAlpha + } + + func minAlphaNearEitherOrigin(px cx: Int, _ cy: Int, radius: Int) -> CGFloat { + let flippedY = (rep.pixelsHigh - 1) - cy + return min(minAlphaNear(px: cx, cy, radius: radius), minAlphaNear(px: cx, flippedY, radius: radius)) + } + + // These are the center pixels for the two Warp eye cutouts in the top bar (36×36 canvas). + // If the eyes are rotated around the wrong origin, these points will not be fully punched out. + let leftEyeCenter = (x: 11, y: 25) + let rightEyeCenter = (x: 25, y: 25) + + // The eye ellipse height is even (8 px), so the exact center can land between pixel rows. + // Assert via a small neighborhood search rather than a single pixel. + #expect(minAlphaNearEitherOrigin(px: leftEyeCenter.x, leftEyeCenter.y, radius: 2) < 0.05) + #expect(minAlphaNearEitherOrigin(px: rightEyeCenter.x, rightEyeCenter.y, radius: 2) < 0.05) + + // Sanity: nearby top bar track area should remain visible (not everything is transparent). + let midAlpha = max(alphaAt(px: 18, 25), alphaAt(px: 18, (rep.pixelsHigh - 1) - 25)) + #expect(midAlpha > 0.05) + } + @Test func accountInfoParsesAuthToken() throws { let tmp = try FileManager.default.url( diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index d076b3185..ca0363a85 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -39,4 +39,16 @@ struct ConfigValidationTests { let issues = CodexBarConfigValidator.validate(config) #expect(issues.contains(where: { $0.code == "token_accounts_unused" })) } + + @Test + func allowsOllamaTokenAccounts() { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ProviderTokenAccount(id: UUID(), label: "a", token: "t", addedAt: 0, lastUsed: nil)], + activeIndex: 0) + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .ollama, tokenAccounts: accounts)) + let issues = CodexBarConfigValidator.validate(config) + #expect(!issues.contains(where: { $0.code == "token_accounts_unused" && $0.provider == .ollama })) + } } diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 695a4843a..edef3a885 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -36,6 +36,17 @@ struct CostUsagePricingTests { #expect(cost != nil) } + @Test + func claudeCostSupportsOpus46DatedVariant() { + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-opus-4-6-20260205", + inputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 5) + #expect(cost != nil) + } + @Test func claudeCostReturnsNilForUnknownModels() { let cost = CostUsagePricing.claudeCostUSD( diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 03e6fac83..e1b35be14 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -122,6 +122,87 @@ struct KiroStatusProbeTests { } } + // MARK: - New Format (kiro-cli 1.24+, Q Developer) + + @Test + func parsesQDeveloperManagedPlan() throws { + let output = """ + Plan: Q Developer Pro + Your plan is managed by admin + + Tip: to see context window usage, run /context + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "Q Developer Pro") + #expect(snapshot.creditsPercent == 0) + #expect(snapshot.creditsUsed == 0) + #expect(snapshot.creditsTotal == 0) + #expect(snapshot.bonusCreditsUsed == nil) + #expect(snapshot.resetsAt == nil) + } + + @Test + func parsesQDeveloperFreePlan() throws { + let output = """ + Plan: Q Developer Free + Your plan is managed by admin + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "Q Developer Free") + #expect(snapshot.creditsPercent == 0) + } + + @Test + func parsesNewFormatWithANSICodes() throws { + let output = """ + \u{001B}[38;5;141mPlan: Q Developer Pro\u{001B}[0m + Your plan is managed by admin + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "Q Developer Pro") + } + + @Test + func rejectsHeaderOnlyNewFormatWithoutManagedMarker() { + let output = """ + Plan: Q Developer Pro + Tip: to see context window usage, run /context + """ + + let probe = KiroStatusProbe() + #expect(throws: KiroStatusProbeError.self) { + try probe.parse(output: output) + } + } + + @Test + func preservesParsedUsageForManagedPlanWithMetrics() throws { + let output = """ + Plan: Q Developer Enterprise + Your plan is managed by admin + ████████████████████████████████████████████████████ 40% + (20.00 of 50 covered in plan), resets on 03/15 + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "Q Developer Enterprise") + #expect(snapshot.creditsPercent == 40) + #expect(snapshot.creditsUsed == 20) + #expect(snapshot.creditsTotal == 50) + #expect(snapshot.resetsAt != nil) + } + // MARK: - Snapshot Conversion @Test diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index a735f6a19..1df1bb3bb 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -391,6 +391,133 @@ struct MenuCardModelTests { #expect(model.providerCost == nil) } + @Test + @MainActor + func openRouterModel_usesAPIKeyQuotaBarAndQuotaDetail() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: 20, + keyUsage: 0.5, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + #expect(model.metrics.count == 1) + #expect(model.usageNotes.isEmpty) + let metric = try #require(model.metrics.first) + let popupTitle = UsageMenuCardView.popupMetricTitle( + provider: .openrouter, + metric: metric) + #expect(popupTitle == "API key limit") + #expect(metric.resetText == "$19.50/$20.00 left") + #expect(metric.detailRightText == nil) + } + + @Test + func openRouterModel_withoutKeyLimitShowsTextOnlySummary() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.creditsText == nil) + #expect(model.placeholder == nil) + #expect(model.usageNotes == ["No limit set for the API key"]) + } + + @Test + func openRouterModel_whenKeyFetchUnavailableShowsUnavailableNote() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.usageNotes == ["API key limit unavailable right now"]) + } + @Test func hidesEmailWhenPersonalInfoHidden() throws { let now = Date() @@ -432,4 +559,49 @@ struct MenuCardModelTests { #expect(model.creditsHintCopyText?.isEmpty == true) #expect(model.creditsHintText?.contains("codex@example.com") == false) } + + @Test + func warpModelShowsPrimaryDetailWhenResetDateMissing() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "10/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.warp]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .warp, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.resetText == nil) + #expect(primary.detailText == "10/100 credits") + } } diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift new file mode 100644 index 000000000..e427484f8 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -0,0 +1,177 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct MiniMaxAPITokenFetchTests { + @Test + func retriesChinaHostWhenGlobalRejectsToken() async throws { + let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) + } + MiniMaxAPITokenStubURLProtocol.handler = nil + MiniMaxAPITokenStubURLProtocol.requests = [] + } + + MiniMaxAPITokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let host = url.host ?? "" + if host == "api.minimax.io" { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if host == "api.minimaxi.com" { + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let body = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now) + + #expect(snapshot.planName == "Max") + #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) + #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") + #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + } + + @Test + func preservesInvalidCredentialsWhenChinaRetryFailsTransport() async throws { + let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) + } + MiniMaxAPITokenStubURLProtocol.handler = nil + MiniMaxAPITokenStubURLProtocol.requests = [] + } + + MiniMaxAPITokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let host = url.host ?? "" + if host == "api.minimax.io" { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if host == "api.minimaxi.com" { + throw URLError(.cannotFindHost) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now) + } + + #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) + #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") + #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + } + + @Test + func doesNotRetryWhenRegionIsChinaMainland() async throws { + let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) + } + MiniMaxAPITokenStubURLProtocol.handler = nil + MiniMaxAPITokenStubURLProtocol.requests = [] + } + + MiniMaxAPITokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let host = url.host ?? "" + if host == "api.minimaxi.com" { + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let body = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .chinaMainland, now: now) + + #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 1) + #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimaxi.com") + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class MiniMaxAPITokenStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "api.minimax.io" || host == "api.minimaxi.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift new file mode 100644 index 000000000..ceff20813 --- /dev/null +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct OllamaUsageFetcherRetryMappingTests { + @Test + func missingUsageShapeSurfacesPublicParseFailedMessage() async { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + OllamaRetryMappingStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = "No usage data rendered." + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let fetcher = OllamaUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + makeURLSession: { delegate in + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self] + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + }) + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=test-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.parseFailed") + } catch let error as OllamaUsageError { + guard case let .parseFailed(message) = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + #expect(message == "Missing Ollama usage data.") + } catch { + Issue.record("Expected OllamaUsageError.parseFailed, got \(error)") + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"])! + return (response, Data(body.utf8)) + } +} + +final class OllamaRetryMappingStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host?.lowercased() else { return false } + return host == "ollama.com" || host == "www.ollama.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift new file mode 100644 index 000000000..8020638f8 --- /dev/null +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -0,0 +1,204 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct OllamaUsageFetcherTests { + @Test + func attachesCookieForOllamaHosts() { + #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings"))) + #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ollama.com"))) + #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ollama.com/path"))) + } + + @Test + func rejectsNonOllamaHosts() { + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com.evil.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) + } + + @Test + func manualModeWithoutValidHeaderThrowsNoSessionCookie() { + do { + _ = try OllamaUsageFetcher.resolveManualCookieHeader( + override: nil, + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + // expected + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func autoModeWithoutHeaderDoesNotForceManualError() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: nil, + manualCookieMode: false) + #expect(resolved == nil) + } + + @Test + func manualModeWithoutRecognizedSessionCookieThrowsNoSessionCookie() { + do { + _ = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "analytics_session_id=noise; theme=dark", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + // expected + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func manualModeWithRecognizedSessionCookieAcceptsHeader() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "next-auth.session-token.0=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("next-auth.session-token.0=abc") == true) + } + + @Test + func retryPolicyRetriesOnlyForAuthErrors() { + #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) + #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.notLoggedIn)) + #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate( + after: OllamaUsageFetcher.RetryableParseFailure.missingUsageData)) + #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate( + after: OllamaUsageError.parseFailed("Missing Ollama usage data."))) + #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate( + after: OllamaUsageError.parseFailed("Unexpected parser mismatch."))) + #expect(!OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.networkError("timeout"))) + } + + #if os(macOS) + @Test + func cookieImporterDefaultsToChromeFirst() { + #expect(OllamaCookieImporter.defaultPreferredBrowsers == [.chrome]) + } + + @Test + func cookieSelectorSkipsSessionLikeNoiseAndFindsRecognizedCookie() throws { + let first = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], + sourceLabel: "Profile A") + let second = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-next-auth.session-token", value: "auth")], + sourceLabel: "Profile B") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [first, second]) + #expect(selected.sourceLabel == "Profile B") + } + + @Test + func cookieSelectorThrowsWhenNoRecognizedSessionCookieExists() { + let candidates = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], + sourceLabel: "Profile A"), + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "tracking_session", value: "noise")], + sourceLabel: "Profile B"), + ] + + do { + _ = try OllamaCookieImporter.selectSessionInfo(from: candidates) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + // expected + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func cookieSelectorAcceptsChunkedNextAuthSessionTokenCookie() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")], + sourceLabel: "Profile C") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "Profile C") + } + + @Test + func cookieSelectorKeepsRecognizedCandidatesInOrder() throws { + let first = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "session", value: "stale")], + sourceLabel: "Chrome Profile A") + let second = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "valid")], + sourceLabel: "Chrome Profile B") + let noise = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], + sourceLabel: "Chrome Profile C") + + let selected = try OllamaCookieImporter.selectSessionInfos(from: [first, noise, second]) + #expect(selected.map(\.sourceLabel) == ["Chrome Profile A", "Chrome Profile B"]) + } + + @Test + func cookieSelectorDoesNotFallbackWhenFallbackDisabled() { + let preferred = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], + sourceLabel: "Chrome Profile"), + ] + let fallback = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")], + sourceLabel: "Safari Profile"), + ] + + do { + _ = try OllamaCookieImporter.selectSessionInfoWithFallback( + preferredCandidates: preferred, + allowFallbackBrowsers: false, + loadFallbackCandidates: { fallback }) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + // expected + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func cookieSelectorFallsBackToNonChromeCandidateWhenFallbackEnabled() throws { + let preferred = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], + sourceLabel: "Chrome Profile"), + ] + let fallback = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")], + sourceLabel: "Safari Profile"), + ] + + let selected = try OllamaCookieImporter.selectSessionInfoWithFallback( + preferredCandidates: preferred, + allowFallbackBrowsers: true, + loadFallbackCandidates: { fallback }) + #expect(selected.sourceLabel == "Safari Profile") + } + + private static func makeCookie( + name: String, + value: String, + domain: String = "ollama.com") -> HTTPCookie + { + HTTPCookie( + properties: [ + .name: name, + .value: value, + .domain: domain, + .path: "/", + ])! + } + #endif +} diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift new file mode 100644 index 000000000..e01d167ae --- /dev/null +++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct OllamaUsageParserTests { + @Test + func parsesCloudUsageFromSettingsHTML() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let html = """ +
+

+ Cloud Usage + free +

+

user@example.com

+
+ Session usage + 0.1% used +
Resets in 3 hours
+
+
+ Weekly usage + 0.7% used +
Resets in 2 days
+
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + #expect(snapshot.planName == "free") + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.sessionUsedPercent == 0.1) + #expect(snapshot.weeklyUsedPercent == 0.7) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedSession = formatter.date(from: "2026-01-30T18:00:00Z") + let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z") + #expect(snapshot.sessionResetsAt == expectedSession) + #expect(snapshot.weeklyResetsAt == expectedWeekly) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.loginMethod == "free") + #expect(usage.identity?.accountEmail == "user@example.com") + } + + @Test + func missingUsageThrowsParseFailed() { + let html = "No usage here. login status unknown." + + #expect { + try OllamaUsageParser.parse(html: html) + } throws: { error in + guard case let OllamaUsageError.parseFailed(message) = error else { return false } + return message.contains("Missing Ollama usage data") + } + } + + @Test + func classifiedParseMissingUsageReturnsTypedFailure() { + let html = "No usage here. login status unknown." + let result = OllamaUsageParser.parseClassified(html: html) + + switch result { + case .success: + Issue.record("Expected classified parse failure for missing usage data") + case let .failure(failure): + #expect(failure == .missingUsageData) + } + } + + @Test + func signedOutThrowsNotLoggedIn() { + let html = """ + + +

Sign in to Ollama

+
+ + +
+ + + """ + + #expect { + try OllamaUsageParser.parse(html: html) + } throws: { error in + guard case OllamaUsageError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func classifiedParseSignedOutReturnsTypedFailure() { + let html = """ + + +

Sign in to Ollama

+
+ + +
+ + + """ + + let result = OllamaUsageParser.parseClassified(html: html) + switch result { + case .success: + Issue.record("Expected classified parse failure for signed-out HTML") + case let .failure(failure): + #expect(failure == .notLoggedIn) + } + } + + @Test + func genericSignInTextWithoutAuthMarkersThrowsParseFailed() { + let html = """ + + +

Usage Dashboard

+

If you have an account, you can sign in from the homepage.

+
No usage rows rendered.
+ + + """ + + #expect { + try OllamaUsageParser.parse(html: html) + } throws: { error in + guard case let OllamaUsageError.parseFailed(message) = error else { return false } + return message.contains("Missing Ollama usage data") + } + } + + @Test + func parsesHourlyUsageAsPrimaryWindow() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let html = """ +
+ Hourly usage + 2.5% used +
Resets in 3 hours
+ Weekly usage + 4.2% used +
Resets in 2 days
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + #expect(snapshot.sessionUsedPercent == 2.5) + #expect(snapshot.weeklyUsedPercent == 4.2) + } + + @Test + func parsesUsageWhenUsedIsCapitalized() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let html = """ +
+ Session usage + 1.2% Used +
Resets in 3 hours
+ Weekly usage + 3.4% USED +
Resets in 2 days
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + #expect(snapshot.sessionUsedPercent == 1.2) + #expect(snapshot.weeklyUsedPercent == 3.4) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift new file mode 100644 index 000000000..f9cb184eb --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +import WebKit +@testable import CodexBarCore + +@Suite +struct OpenAIDashboardNavigationDelegateTests { + @Test("ignores NSURLErrorCancelled") + func ignoresCancelledNavigationError() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + #expect(NavigationDelegate.shouldIgnoreNavigationError(error)) + } + + @Test("does not ignore non-cancelled URL errors") + func doesNotIgnoreOtherURLErrors() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + #expect(!NavigationDelegate.shouldIgnoreNavigationError(error)) + } + + @MainActor + @Test("cancelled failure is ignored until finish") + func cancelledFailureIsIgnoredUntilFinish() { + let webView = WKWebView() + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.webView(webView, didFail: nil, withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) + #expect(result == nil) + delegate.webView(webView, didFinish: nil) + + switch result { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test("cancelled provisional failure is ignored until real failure") + func cancelledProvisionalFailureIsIgnoredUntilRealFailure() { + let webView = WKWebView() + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.webView( + webView, + didFailProvisionalNavigation: nil, + withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) + #expect(result == nil) + + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + delegate.webView(webView, didFailProvisionalNavigation: nil, withError: timeout) + + switch result { + case let .failure(error as NSError)?: + #expect(error.domain == NSURLErrorDomain) + #expect(error.code == NSURLErrorTimedOut) + default: + #expect(Bool(false)) + } + } +} diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift new file mode 100644 index 000000000..d64ffb06b --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -0,0 +1,247 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct OpenCodeUsageFetcherErrorTests { + @Test + func extractsApiErrorFromUppercaseHTMLTitle() async throws { + let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + } + OpenCodeStubURLProtocol.handler = nil + } + + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = "403 Forbiddendenied" + return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "text/html") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123") + Issue.record("Expected OpenCodeUsageError.apiError") + } catch let error as OpenCodeUsageError { + switch error { + case let .apiError(message): + #expect(message.contains("HTTP 500")) + #expect(message.contains("403 Forbidden")) + default: + Issue.record("Expected apiError, got: \(error)") + } + } + } + + @Test + func extractsApiErrorFromDetailField() async throws { + let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + } + OpenCodeStubURLProtocol.handler = nil + } + + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = #"{"detail":"Workspace missing"}"# + return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123") + Issue.record("Expected OpenCodeUsageError.apiError") + } catch let error as OpenCodeUsageError { + switch error { + case let .apiError(message): + #expect(message.contains("HTTP 500")) + #expect(message.contains("Workspace missing")) + default: + Issue.record("Expected apiError, got: \(error)") + } + } + } + + @Test + func subscriptionGetNullSkipsPostAndReturnsGracefulError() async throws { + let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + } + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + var urls: [URL] = [] + var queries: [String] = [] + var contentTypes: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + urls.append(url) + queries.append(url.query ?? "") + contentTypes.append(request.value(forHTTPHeaderField: "Content-Type") ?? "") + + if request.httpMethod?.uppercased() == "GET" { + return Self.makeResponse(url: url, body: "null", statusCode: 200, contentType: "application/json") + } + + let body = #"{"status":500,"unhandled":true,"message":"HTTPError"}"# + return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123") + Issue.record("Expected OpenCodeUsageError.apiError") + } catch let error as OpenCodeUsageError { + switch error { + case let .apiError(message): + #expect(message.contains("No subscription usage data")) + #expect(message.contains("wrk_TEST123")) + default: + Issue.record("Expected apiError, got: \(error)") + } + } + + #expect(methods == ["GET"]) + #expect(queries[0].contains("id=")) + #expect(queries[0].contains("wrk_TEST123")) + #expect(urls[0].path == "/_server") + #expect(contentTypes[0].isEmpty) + } + + @Test + func subscriptionGetPayloadDoesNotFallbackToPost() async throws { + let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + } + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + + let body = """ + { + "rollingUsage": { "usagePercent": 17, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + let snapshot = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123") + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(methods == ["GET"]) + } + + @Test + func subscriptionGetMissingFieldsFallsBackToPost() async throws { + let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + } + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + + if request.httpMethod?.uppercased() == "GET" { + return Self.makeResponse( + url: url, + body: #"{"ok":true}"#, + statusCode: 200, + contentType: "application/json") + } + + let body = """ + { + "rollingUsage": { "usagePercent": 22, "resetInSec": 300 }, + "weeklyUsage": { "usagePercent": 44, "resetInSec": 3600 } + } + """ + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "application/json") + } + + let snapshot = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123") + + #expect(snapshot.rollingUsagePercent == 22) + #expect(snapshot.weeklyUsagePercent == 44) + #expect(methods == ["GET", "POST"]) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int, + contentType: String) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, Data(body.utf8)) + } +} + +final class OpenCodeStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "opencode.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift new file mode 100644 index 000000000..25ec01bcf --- /dev/null +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -0,0 +1,255 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenRouterUsageStatsTests { + @Test + func toUsageSnapshot_usesKeyQuotaForPrimaryWindow() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: 20, + keyUsage: 5, + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.openRouterUsage?.keyQuotaStatus == .available) + } + + @Test + func toUsageSnapshot_withoutValidKeyLimitOmitsPrimaryWindow() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.openRouterUsage?.keyQuotaStatus == .unavailable) + } + + @Test + func toUsageSnapshot_whenNoLimitConfiguredOmitsPrimaryAndMarksNoLimit() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + } + + @Test + func sanitizers_redactSensitiveTokenShapes() { + let body = """ + {"error":"bad token sk-or-v1-abc123","token":"secret-token","authorization":"Bearer sk-or-v1-xyz789"} + """ + + let summary = OpenRouterUsageFetcher._sanitizedResponseBodySummaryForTesting(body) + let debugBody = OpenRouterUsageFetcher._redactedDebugResponseBodyForTesting(body) + + #expect(summary.contains("sk-or-v1-[REDACTED]")) + #expect(summary.contains("\"token\":\"[REDACTED]\"")) + #expect(!summary.contains("secret-token")) + #expect(!summary.contains("sk-or-v1-abc123")) + + #expect(debugBody?.contains("sk-or-v1-[REDACTED]") == true) + #expect(debugBody?.contains("\"token\":\"[REDACTED]\"") == true) + #expect(debugBody?.contains("secret-token") == false) + #expect(debugBody?.contains("sk-or-v1-xyz789") == false) + } + + @Test + func non200FetchThrowsGenericHTTPErrorWithoutBodyDetails() async throws { + let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self) + } + OpenRouterStubURLProtocol.handler = nil + } + + OpenRouterStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = #"{"error":"invalid sk-or-v1-super-secret","token":"dont-leak-me"}"# + return Self.makeResponse(url: url, body: body, statusCode: 401) + } + + do { + _ = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: "sk-or-v1-test", + environment: ["OPENROUTER_API_URL": "https://openrouter.test/api/v1"]) + Issue.record("Expected OpenRouterUsageError.apiError") + } catch let error as OpenRouterUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got: \(error)") + return + } + #expect(message == "HTTP 401") + #expect(!message.contains("dont-leak-me")) + #expect(!message.contains("sk-or-v1-super-secret")) + } + } + + @Test + func fetchUsage_setsCreditsTimeoutAndClientHeaders() async throws { + let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self) + } + OpenRouterStubURLProtocol.handler = nil + } + + OpenRouterStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/api/v1/credits": + #expect(request.timeoutInterval == 15) + #expect(request.value(forHTTPHeaderField: "HTTP-Referer") == "https://codexbar.example") + #expect(request.value(forHTTPHeaderField: "X-Title") == "CodexBar QA") + let body = #"{"data":{"total_credits":100,"total_usage":40}}"# + return Self.makeResponse(url: url, body: body, statusCode: 200) + case "/api/v1/key": + let body = #"{"data":{"limit":20,"usage":0.5,"rate_limit":{"requests":120,"interval":"10s"}}}"# + return Self.makeResponse(url: url, body: body, statusCode: 200) + default: + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let usage = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: "sk-or-v1-test", + environment: [ + "OPENROUTER_API_URL": "https://openrouter.test/api/v1", + "OPENROUTER_HTTP_REFERER": " https://codexbar.example ", + "OPENROUTER_X_TITLE": "CodexBar QA", + ]) + + #expect(usage.totalCredits == 100) + #expect(usage.totalUsage == 40) + #expect(usage.keyDataFetched) + #expect(usage.keyLimit == 20) + #expect(usage.keyUsage == 0.5) + #expect(usage.keyRemaining == 19.5) + #expect(usage.keyUsedPercent == 2.5) + #expect(usage.keyQuotaStatus == .available) + } + + @Test + func fetchUsage_whenKeyEndpointFailsMarksQuotaUnavailable() async throws { + let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self) + } + OpenRouterStubURLProtocol.handler = nil + } + + OpenRouterStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/api/v1/credits": + let body = #"{"data":{"total_credits":100,"total_usage":40}}"# + return Self.makeResponse(url: url, body: body, statusCode: 200) + case "/api/v1/key": + return Self.makeResponse(url: url, body: "{}", statusCode: 500) + default: + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let usage = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: "sk-or-v1-test", + environment: ["OPENROUTER_API_URL": "https://openrouter.test/api/v1"]) + + #expect(!usage.keyDataFetched) + #expect(usage.keyQuotaStatus == .unavailable) + } + + @Test + func usageSnapshot_roundTripPersistsOpenRouterUsageMetadata() throws { + let openRouter = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + let snapshot = openRouter.toUsageSnapshot() + + let encoder = JSONEncoder() + let data = try encoder.encode(snapshot) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: data) + + #expect(decoded.openRouterUsage?.keyDataFetched == true) + #expect(decoded.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class OpenRouterStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "openrouter.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index ff20e3952..cedeeca59 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -30,6 +30,7 @@ struct PreferencesPaneSmokeTests { settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true settings.debugDisableKeychainAccess = true + settings.claudeOAuthKeychainPromptMode = .always settings.refreshFrequency = .manual let store = Self.makeUsageStore(settings: settings) diff --git a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift new file mode 100644 index 000000000..4632d1b95 --- /dev/null +++ b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift @@ -0,0 +1,120 @@ +import Testing +@testable import CodexBarCore + +@Suite +struct ProviderCandidateRetryRunnerTests { + private enum TestError: Error, Equatable { + case retryable(Int) + case nonRetryable(Int) + } + + @Test + func retriesThenSucceeds() async throws { + let candidates = [1, 2, 3] + var attempted: [Int] = [] + var retried: [Int] = [] + + let output = try await ProviderCandidateRetryRunner.run( + candidates, + shouldRetry: { error in + if case TestError.retryable = error { + return true + } + return false + }, + onRetry: { candidate, _ in + retried.append(candidate) + }, + attempt: { candidate in + attempted.append(candidate) + guard candidate == 3 else { + throw TestError.retryable(candidate) + } + return candidate * 10 + }) + + #expect(output == 30) + #expect(attempted == [1, 2, 3]) + #expect(retried == [1, 2]) + } + + @Test + func nonRetryableFailsImmediately() async { + let candidates = [1, 2, 3] + var attempted: [Int] = [] + var retried: [Int] = [] + + do { + _ = try await ProviderCandidateRetryRunner.run( + candidates, + shouldRetry: { error in + if case TestError.retryable = error { + return true + } + return false + }, + onRetry: { candidate, _ in + retried.append(candidate) + }, + attempt: { candidate in + attempted.append(candidate) + throw TestError.nonRetryable(candidate) + }) + Issue.record("Expected TestError.nonRetryable") + } catch let error as TestError { + #expect(error == .nonRetryable(1)) + #expect(attempted == [1]) + #expect(retried.isEmpty) + } catch { + Issue.record("Expected TestError.nonRetryable(1), got \(error)") + } + } + + @Test + func exhaustedRetryableThrowsLastError() async { + let candidates = [1, 2] + var attempted: [Int] = [] + var retried: [Int] = [] + + do { + _ = try await ProviderCandidateRetryRunner.run( + candidates, + shouldRetry: { error in + if case TestError.retryable = error { + return true + } + return false + }, + onRetry: { candidate, _ in + retried.append(candidate) + }, + attempt: { candidate in + attempted.append(candidate) + throw TestError.retryable(candidate) + }) + Issue.record("Expected TestError.retryable") + } catch let error as TestError { + #expect(error == .retryable(2)) + #expect(attempted == [1, 2]) + #expect(retried == [1]) + } catch { + Issue.record("Expected TestError.retryable(2), got \(error)") + } + } + + @Test + func emptyCandidatesThrowsNoCandidates() async { + do { + let candidates: [Int] = [] + _ = try await ProviderCandidateRetryRunner.run( + candidates, + shouldRetry: { _ in true }, + attempt: { _ in 1 }) + Issue.record("Expected ProviderCandidateRetryRunnerError.noCandidates") + } catch ProviderCandidateRetryRunnerError.noCandidates { + // expected + } catch { + Issue.record("Expected ProviderCandidateRetryRunnerError.noCandidates, got \(error)") + } + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index e61a2e3d1..88f1b35c7 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -14,6 +14,44 @@ struct ProviderConfigEnvironmentTests { #expect(env[ZaiSettingsReader.apiTokenKey] == "z-token") } + @Test + func appliesAPIKeyOverrideForWarp() { + let config = ProviderConfig(id: .warp, apiKey: "w-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .warp, + config: config) + + let key = WarpSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "w-token") + } + + @Test + func appliesAPIKeyOverrideForOpenRouter() { + let config = ProviderConfig(id: .openrouter, apiKey: "or-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .openrouter, + config: config) + + #expect(env[OpenRouterSettingsReader.envKey] == "or-token") + } + + @Test + func openRouterConfigOverrideWinsOverEnvironmentToken() { + let config = ProviderConfig(id: .openrouter, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [OpenRouterSettingsReader.envKey: "env-token"], + provider: .openrouter, + config: config) + + #expect(env[OpenRouterSettingsReader.envKey] == "config-token") + #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token") + } + @Test func leavesEnvironmentWhenAPIKeyMissing() { let config = ProviderConfig(id: .zai, apiKey: nil) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 9e05c7462..74ba76f8f 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -134,6 +134,7 @@ struct ProviderSettingsDescriptorTests { configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.debugDisableKeychainAccess = false let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), @@ -161,6 +162,100 @@ struct ProviderSettingsDescriptorTests { let pickers = ClaudeProviderImplementation().settingsPickers(context: context) #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) + let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) + let optionIDs = Set(keychainPicker.options.map(\.id)) + #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.never.rawValue)) + #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.onlyOnUserAction.rawValue)) + #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.always.rawValue)) + #expect(keychainPicker.isEnabled?() ?? true) + } + + @Test + func claudePromptPolicyPickerHiddenWhenExperimentalReaderSelected() throws { + let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.debugDisableKeychainAccess = false + settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .claude, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let pickers = ClaudeProviderImplementation().settingsPickers(context: context) + let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) + #expect(keychainPicker.isVisible?() == false) + } + + @Test + func claudeKeychainPromptPolicyPickerDisabledWhenGlobalKeychainDisabled() throws { + let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.debugDisableKeychainAccess = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let context = ProviderSettingsContext( + provider: .claude, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + + let pickers = ClaudeProviderImplementation().settingsPickers(context: context) + let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) + #expect(keychainPicker.isEnabled?() == false) + let subtitle = keychainPicker.dynamicSubtitle?() ?? "" + #expect(subtitle.localizedCaseInsensitiveContains("inactive")) } @Test diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 004b63b15..867b3ad45 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -17,4 +17,26 @@ struct ProviderTokenResolverTests { let resolution = ProviderTokenResolver.copilotResolution(environment: env) #expect(resolution?.token == "token") } + + @Test + func warpResolutionUsesEnvironmentToken() { + let env = ["WARP_API_KEY": "wk-test-token"] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution?.token == "wk-test-token") + #expect(resolution?.source == .environment) + } + + @Test + func warpResolutionTrimsToken() { + let env = ["WARP_API_KEY": " wk-token "] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution?.token == "wk-token") + } + + @Test + func warpResolutionReturnsNilWhenMissing() { + let env: [String: String] = [:] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution == nil) + } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index e3f2bd553..e5ea668de 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -14,6 +14,39 @@ struct ProvidersPaneCoverageTests { ProvidersPaneTestHarness.exercise(settings: settings, store: store) } + @Test + func openRouterMenuBarMetricPicker_showsOnlyAutomaticAndPrimary() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .openrouter) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (API key limit)", + ]) + } + + @Test + func providerDetailPlanRow_formatsOpenRouterAsBalance() { + let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + + #expect(row?.label == "Balance") + #expect(row?.value == "$4.61") + } + + @Test + func providerDetailPlanRow_keepsPlanLabelForNonOpenRouter() { + let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + + #expect(row?.label == "Plan") + #expect(row?.value == "Pro") + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index c70eb957b..7ec91584c 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -20,6 +20,20 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .gemini) == .average) } + @Test + func menuBarMetricPreferenceRestrictsOpenRouterToAutomaticOrPrimary() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-openrouter-metric") + + settings.setMenuBarMetricPreference(.secondary, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.average, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.primary, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .primary) + } + @Test func minimaxAuthModeUsesStoredValues() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax") @@ -42,6 +56,16 @@ struct SettingsStoreAdditionalTests { #expect(settings.claudeCookieSource == .manual) } + @Test + func ollamaTokenAccountsSetManualCookieSourceWhenRequired() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-ollama-token-accounts") + + settings.addTokenAccount(provider: .ollama, label: "Primary", token: "session=token-1") + + #expect(settings.tokenAccounts(for: .ollama).count == 1) + #expect(settings.ollamaCookieSource == .manual) + } + @Test func detectsTokenCostUsageSourcesFromFilesystem() throws { let fm = FileManager.default diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 724e8dece..aa3c096b8 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -124,6 +124,7 @@ struct SettingsStoreCoverageTests { settings.ensureKimiK2APITokenLoaded() settings.ensureAugmentCookieLoaded() settings.ensureAmpCookieLoaded() + settings.ensureOllamaCookieLoaded() settings.ensureCopilotAPITokenLoaded() settings.ensureTokenAccountsLoaded() @@ -149,6 +150,88 @@ struct SettingsStoreCoverageTests { #expect(settings.kimiCookieSource == .off) } + @Test + func claudeKeychainPromptMode_defaultsToOnlyOnUserAction() { + let settings = Self.makeSettingsStore() + #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) + } + + @Test + func claudeKeychainPromptMode_persistsAcrossStoreReload() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + first.claudeOAuthKeychainPromptMode = .never + #expect( + defaults.string(forKey: "claudeOAuthKeychainPromptMode") + == ClaudeOAuthKeychainPromptMode.never.rawValue) + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.claudeOAuthKeychainPromptMode == .never) + } + + @Test + func claudeKeychainPromptMode_invalidRawFallsBackToOnlyOnUserAction() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode-invalid" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("invalid-mode", forKey: "claudeOAuthKeychainPromptMode") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) + } + + @Test + func claudeKeychainReadStrategy_defaultsToSecurityFramework() { + let settings = Self.makeSettingsStore() + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + } + + @Test + func claudeKeychainReadStrategy_persistsAcrossStoreReload() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + #expect( + defaults.string(forKey: "claudeOAuthKeychainReadStrategy") + == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue) + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + } + + @Test + func claudeKeychainReadStrategy_invalidRawFallsBackToSecurityFramework() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-invalid" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("invalid-strategy", forKey: "claudeOAuthKeychainReadStrategy") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + } + + @Test + func claudePromptFreeCredentialsToggle_mapsToReadStrategy() { + let settings = Self.makeSettingsStore() + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false) + + settings.claudeOAuthPromptFreeCredentialsEnabled = true + #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + + settings.claudeOAuthPromptFreeCredentialsEnabled = false + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + } + private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index fad99a763..cfeb90048 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -363,7 +363,10 @@ struct SettingsStoreTests { .jetbrains, .kimik2, .amp, + .ollama, .synthetic, + .warp, + .openrouter, ]) // Move one provider; ensure it's persisted across instances. diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index cfb94bb72..c103ba4ea 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -6,6 +6,19 @@ import Testing @MainActor @Suite struct StatusItemAnimationTests { + private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat { + var maxAlpha: CGFloat = 0 + for x in 0.. maxAlpha { + maxAlpha = alpha + } + } + } + return maxAlpha + } + private func makeStatusBarForTesting() -> NSStatusBar { let env = ProcessInfo.processInfo.environment if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { @@ -124,6 +137,112 @@ struct StatusItemAnimationTests { #expect(alpha > 0.05) } + @Test + func warpNoBonusLayoutIsPreservedInShowUsedModeWhenBonusIsExhausted() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-no-bonus-used"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let warpMeta = registry.metadata[.warp] { + settings.setProviderEnabled(provider: .warp, metadata: warpMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .warp) + store._setErrorForTesting(nil, provider: .warp) + + controller.applyIcon(for: .warp, phase: nil) + + guard let image = controller.statusItems[.warp]?.button?.image else { + #expect(Bool(false)) + return + } + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + guard let rep else { return } + + // In the Warp "no bonus/exhausted bonus" layout, the bottom bar is a dimmed track. + // A pixel near the right side of the bottom bar should remain subdued (not fully opaque). + let alpha = (rep.colorAt(x: 25, y: 9) ?? .clear).alphaComponent + #expect(alpha < 0.6) + } + + @Test + func warpBonusLaneIsPreservedInShowUsedModeWhenBonusIsUnused() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-unused-bonus-used"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let warpMeta = registry.metadata[.warp] { + settings.setProviderEnabled(provider: .warp, metadata: warpMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + // Bonus exists but is unused: used=0% (remaining=100%). + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .warp) + store._setErrorForTesting(nil, provider: .warp) + + controller.applyIcon(for: .warp, phase: nil) + + guard let image = controller.statusItems[.warp]?.button?.image else { + #expect(Bool(false)) + return + } + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + guard let rep else { return } + + // When we incorrectly treat "0 used" as "no bonus", the Warp branch makes the top bar full (100%). + // A pixel near the right side of the top bar should remain in the track-only range for 10% usage. + let alpha = (rep.colorAt(x: 31, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.6) + } + @Test func menuBarPercentUsesConfiguredMetric() { let settings = SettingsStore( @@ -163,6 +282,45 @@ struct StatusItemAnimationTests { #expect(window?.usedPercent == 42) } + @Test + func menuBarPercentAutomaticPrefersRateLimitForKimi() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kimi-automatic"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .kimi + settings.setMenuBarMetricPreference(.automatic, for: .kimi) + + let registry = ProviderRegistry.shared + if let kimiMeta = registry.metadata[.kimi] { + settings.setProviderEnabled(provider: .kimi, metadata: kimiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .kimi) + store._setErrorForTesting(nil, provider: .kimi) + + let window = controller.menuBarMetricWindow(for: .kimi, snapshot: snapshot) + + #expect(window?.usedPercent == 42) + } + @Test func menuBarPercentUsesAverageForGemini() { let settings = SettingsStore( @@ -341,4 +499,130 @@ struct StatusItemAnimationTests { #expect(percentRaw == "weekly") #expect(paceRaw == "session") } + + @Test + func menuBarDisplayTextUsesCreditsWhenCodexWeeklyIsExhausted() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.secondary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remainingCredits = (snapshot.primary?.usedPercent ?? 0) * 4.5 + (snapshot.secondary?.usedPercent ?? 0) / 10 + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + let expected = UsageFormatter + .creditsString(from: remainingCredits) + .replacingOccurrences(of: " left", with: "") + + #expect(displayText == expected) + } + + @Test + func menuBarDisplayTextUsesCreditsWhenCodexSessionIsExhausted() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback-session"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remainingCredits = (snapshot.primary?.usedPercent ?? 0) - (snapshot.secondary?.usedPercent ?? 0) / 2 + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + let expected = UsageFormatter + .creditsString(from: remainingCredits) + .replacingOccurrences(of: " left", with: "") + + #expect(displayText == expected) + } + + @Test + func brandImageWithStatusOverlayReturnsOriginalImageWhenNoIssue() { + let brand = NSImage(size: NSSize(width: 16, height: 16)) + brand.isTemplate = true + + let output = StatusItemController.brandImageWithStatusOverlay(brand: brand, statusIndicator: .none) + + #expect(output === brand) + } + + @Test + func brandImageWithStatusOverlayDrawsIssueMark() throws { + let size = NSSize(width: 16, height: 16) + let brand = NSImage(size: size) + brand.lockFocus() + NSColor.clear.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + brand.unlockFocus() + brand.isTemplate = true + + let baselineData = try #require(brand.tiffRepresentation) + let baselineRep = try #require(NSBitmapImageRep(data: baselineData)) + let baselineAlpha = self.maxAlpha(in: baselineRep) + + let output = StatusItemController.brandImageWithStatusOverlay(brand: brand, statusIndicator: .major) + + #expect(output !== brand) + let outputData = try #require(output.tiffRepresentation) + let outputRep = try #require(NSBitmapImageRep(data: outputData)) + let outputAlpha = self.maxAlpha(in: outputRep) + #expect(baselineAlpha < 0.01) + #expect(outputAlpha > 0.01) + } } diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 9e83f1238..976a0d965 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -50,4 +50,59 @@ struct StatusItemControllerMenuTests { #expect(percent == 80) } + + @Test + func openRouterBrandFallbackEnabledWhenNoKeyLimitConfigured() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(StatusItemController.shouldUseOpenRouterBrandFallback( + provider: .openrouter, + snapshot: snapshot)) + #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + } + + @Test + func openRouterBrandFallbackDisabledWhenKeyQuotaFetchUnavailable() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( + provider: .openrouter, + snapshot: snapshot)) + } + + @Test + func openRouterBrandFallbackDisabledWhenKeyQuotaAvailable() { + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyLimit: 20, + keyUsage: 2, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( + provider: .openrouter, + snapshot: snapshot)) + #expect(snapshot.primary?.usedPercent == 10) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 37d33c5e8..212ecd897 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -75,6 +75,161 @@ struct StatusMenuTests { #expect(controller.lastMenuProvider == .codex) } + @Test + func mergedMenuOpenDoesNotPersistResolvedProviderWhenSelectionIsNil() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = nil + + let registry = ProviderRegistry.shared + var enabledProviders: [UsageProvider] = [] + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = enabledProviders.count < 2 + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + if shouldEnable { + enabledProviders.append(provider) + } + } + #expect(enabledProviders.count == 2) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let expectedResolved = store.enabledProviders().first ?? .codex + #expect(store.enabledProviders().count > 1) + #expect(controller.shouldMergeIcons == true) + let menu = controller.makeMenu() + #expect(settings.selectedMenuProvider == nil) + controller.menuWillOpen(menu) + #expect(settings.selectedMenuProvider == nil) + #expect(controller.lastMenuProvider == expectedResolved) + } + + @Test + func mergedMenuRefreshUsesResolvedEnabledProviderWhenPersistedSelectionIsDisabled() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let expectedResolved = store.enabledProviders().first ?? .codex + #expect(store.enabledProviders().count > 1) + #expect(controller.shouldMergeIcons == true) + + func hasOpenAIWebSubmenus(_ menu: NSMenu) -> Bool { + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } + let hasUsageBreakdown = usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true + let hasCreditsHistory = creditsItem?.submenu?.items + .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + return hasUsageBreakdown || hasCreditsHistory + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + #expect(controller.lastMenuProvider == expectedResolved) + #expect(settings.selectedMenuProvider == .codex) + #expect(hasOpenAIWebSubmenus(menu) == false) + + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(hasOpenAIWebSubmenus(menu) == false) + } + + @Test + func openMergedMenuRebuildsSwitcherWhenUsageBarsModeChanges() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.usageBarsShowUsed = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(store.enabledProviders().count == 2) + #expect(controller.shouldMergeIcons == true) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(initialSwitcher != nil) + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + + settings.usageBarsShowUsed = true + controller.handleProviderConfigChange(reason: "usageBarsShowUsed") + + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(updatedSwitcher != nil) + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + } + } + @Test func providerToggleUpdatesStatusItemVisibility() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 4c0257067..7d18f52eb 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -112,24 +112,28 @@ struct TTYCommandRunnerEnvTests { let script = """ #!/bin/sh echo "hello" - sleep 10 + sleep 30 """ try script.write(to: scriptURL, atomically: true, encoding: .utf8) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) let runner = TTYCommandRunner() - let timeout: TimeInterval = 12 - let scriptedSleep: TimeInterval = 10 - let startedAt = Date() - let result = try runner.run( - binary: scriptURL.path, - send: "", - options: .init(timeout: timeout, idleTimeout: 0.2)) - let elapsed = Date().timeIntervalSince(startedAt) - - #expect(result.text.contains("hello")) - // CI runners can delay PTY scheduling/reads; assert we stop well before script completion. - #expect(elapsed < (scriptedSleep - 1.0)) + let timeout: TimeInterval = 6 + var fastestElapsed = TimeInterval.greatestFiniteMagnitude + // CI can occasionally pause a test process long enough to miss an idle window. + // Retry once and assert that at least one run exits well before timeout. + for _ in 0..<2 { + let startedAt = Date() + let result = try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: timeout, idleTimeout: 0.2)) + let elapsed = Date().timeIntervalSince(startedAt) + + #expect(result.text.contains("hello")) + fastestElapsed = min(fastestElapsed, elapsed) + } + #expect(fastestElapsed < (timeout - 1.0)) } @Test diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 5cd8d0857..cb36b24ae 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -45,6 +45,76 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func ollamaTokenAccountSelectionForcesManualCookieSourceInCLISettingsSnapshot() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "session=account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .ollama, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .ollama).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .ollama, account: account)) + let ollamaSettings = try #require(snapshot.ollama) + + #expect(ollamaSettings.cookieSource == .manual) + #expect(ollamaSettings.manualCookieHeader == "session=account-token") + } + + @Test + func applyAccountLabelInAppPreservesSnapshotFields() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app") + let store = Self.makeUsageStore(settings: settings) + let snapshot = Self.makeSnapshotWithAllFields(provider: .zai) + let account = ProviderTokenAccount( + id: UUID(), + label: "Team Account", + token: "account-token", + addedAt: 0, + lastUsed: nil) + + let labeled = store.applyAccountLabel(snapshot, provider: .zai, account: account) + + Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled) + #expect(labeled.identity?.providerID == .zai) + #expect(labeled.identity?.accountEmail == "Team Account") + } + + @Test + func applyAccountLabelInCLIPreservesSnapshotFields() throws { + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: CodexBarConfig(providers: []), + verbose: false) + let snapshot = Self.makeSnapshotWithAllFields(provider: .zai) + let account = ProviderTokenAccount( + id: UUID(), + label: "CLI Account", + token: "account-token", + addedAt: 0, + lastUsed: nil) + + let labeled = context.applyAccountLabel(snapshot, provider: .zai, account: account) + + Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled) + #expect(labeled.identity?.providerID == .zai) + #expect(labeled.identity?.accountEmail == "CLI Account") + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -69,4 +139,85 @@ struct TokenAccountEnvironmentPrecedenceTests { copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) } + + private static func makeUsageStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let tokenLimit = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 6, + usage: 200, + currentValue: 40, + remaining: 160, + percentage: 20, + usageDetails: [ZaiUsageDetail(modelCode: "glm-4", usage: 40)], + nextResetTime: reset) + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: nil, + accountOrganization: "Org", + loginMethod: "Pro") + + return UsageSnapshot( + primary: RateWindow(usedPercent: 21, windowMinutes: 60, resetsAt: reset, resetDescription: "primary"), + secondary: RateWindow(usedPercent: 42, windowMinutes: 1440, resetsAt: nil, resetDescription: "secondary"), + tertiary: RateWindow(usedPercent: 7, windowMinutes: nil, resetsAt: nil, resetDescription: "tertiary"), + providerCost: ProviderCostSnapshot( + used: 12.5, + limit: 25, + currencyCode: "USD", + period: "Monthly", + resetsAt: reset, + updatedAt: now), + zaiUsage: ZaiUsageSnapshot( + tokenLimit: tokenLimit, + timeLimit: nil, + planName: "Z.ai Pro", + updatedAt: now), + minimaxUsage: MiniMaxUsageSnapshot( + planName: "MiniMax", + availablePrompts: 500, + currentPrompts: 120, + remainingPrompts: 380, + windowMinutes: 1440, + usedPercent: 24, + resetsAt: reset, + updatedAt: now), + openRouterUsage: OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 10, + balance: 40, + usedPercent: 20, + rateLimit: nil, + updatedAt: now), + cursorRequests: CursorRequestUsage(used: 7, limit: 70), + updatedAt: now, + identity: identity) + } + + private static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) { + #expect(after.primary?.usedPercent == before.primary?.usedPercent) + #expect(after.secondary?.usedPercent == before.secondary?.usedPercent) + #expect(after.tertiary?.usedPercent == before.tertiary?.usedPercent) + #expect(after.providerCost?.used == before.providerCost?.used) + #expect(after.providerCost?.limit == before.providerCost?.limit) + #expect(after.providerCost?.currencyCode == before.providerCost?.currencyCode) + #expect(after.zaiUsage?.planName == before.zaiUsage?.planName) + #expect(after.zaiUsage?.tokenLimit?.usage == before.zaiUsage?.tokenLimit?.usage) + #expect(after.minimaxUsage?.planName == before.minimaxUsage?.planName) + #expect(after.minimaxUsage?.availablePrompts == before.minimaxUsage?.availablePrompts) + #expect(after.openRouterUsage?.balance == before.openRouterUsage?.balance) + #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) + #expect(after.cursorRequests?.used == before.cursorRequests?.used) + #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.updatedAt == before.updatedAt) + } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 889ff4f20..32ea42851 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -71,6 +71,34 @@ struct UsageStoreCoverageTests { #expect(label.contains("openai-web")) } + @Test + func providerWithHighestUsagePrefersKimiRateLimitWindow() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kimi-highest") + let store = Self.makeUsageStore(settings: settings) + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .kimi, metadata: #require(metadata[.kimi]), enabled: true) + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: now), + provider: .kimi) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .kimi) + #expect(highest?.usedPercent == 80) + } + @Test func providerAvailabilityAndSubscriptionDetection() { let zaiStore = InMemoryZaiTokenStore(value: "zai-token") diff --git a/Tests/CodexBarTests/WarpUsageFetcherTests.swift b/Tests/CodexBarTests/WarpUsageFetcherTests.swift new file mode 100644 index 000000000..9fb74999c --- /dev/null +++ b/Tests/CodexBarTests/WarpUsageFetcherTests.swift @@ -0,0 +1,231 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct WarpUsageFetcherTests { + @Test + func parsesSnapshotAndAggregatesBonusCredits() throws { + let json = """ + { + "data": { + "user": { + "__typename": "UserOutput", + "user": { + "requestLimitInfo": { + "isUnlimited": false, + "nextRefreshTime": "2026-02-28T19:16:33.462988Z", + "requestLimit": 1500, + "requestsUsedSinceLastRefresh": 5 + }, + "bonusGrants": [ + { + "requestCreditsGranted": 20, + "requestCreditsRemaining": 10, + "expiration": "2026-03-01T10:00:00Z" + } + ], + "workspaces": [ + { + "bonusGrantsInfo": { + "grants": [ + { + "requestCreditsGranted": "15", + "requestCreditsRemaining": "5", + "expiration": "2026-03-15T10:00:00Z" + } + ] + } + } + ] + } + } + } + } + """ + + let snapshot = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedRefresh = formatter.date(from: "2026-02-28T19:16:33.462988Z") + let expectedExpiry = ISO8601DateFormatter().date(from: "2026-03-01T10:00:00Z") + + #expect(snapshot.requestLimit == 1500) + #expect(snapshot.requestsUsed == 5) + #expect(snapshot.isUnlimited == false) + #expect(snapshot.nextRefreshTime != nil) + #expect(abs((snapshot.nextRefreshTime?.timeIntervalSince1970 ?? 0) - + (expectedRefresh?.timeIntervalSince1970 ?? 0)) + < 0.5) + #expect(snapshot.bonusCreditsTotal == 35) + #expect(snapshot.bonusCreditsRemaining == 15) + #expect(snapshot.bonusNextExpirationRemaining == 10) + #expect(abs((snapshot.bonusNextExpiration?.timeIntervalSince1970 ?? 0) - + (expectedExpiry?.timeIntervalSince1970 ?? 0)) + < 0.5) + } + + @Test + func graphQLErrorsThrowAPIError() { + let json = """ + { + "errors": [ + { "message": "Unauthorized" } + ] + } + """ + + #expect { + _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + } throws: { error in + guard case let WarpUsageError.apiError(code, message) = error else { return false } + return code == 200 && message.contains("Unauthorized") + } + } + + @Test + func nullUnlimitedAndStringNumericsParseSafely() throws { + let json = """ + { + "data": { + "user": { + "__typename": "UserOutput", + "user": { + "requestLimitInfo": { + "isUnlimited": null, + "nextRefreshTime": "2026-02-28T19:16:33Z", + "requestLimit": "1500", + "requestsUsedSinceLastRefresh": "5" + } + } + } + } + } + """ + + let snapshot = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + + #expect(snapshot.isUnlimited == false) + #expect(snapshot.requestLimit == 1500) + #expect(snapshot.requestsUsed == 5) + #expect(snapshot.nextRefreshTime != nil) + } + + @Test + func unexpectedTypenameReturnsParseError() { + let json = """ + { + "data": { + "user": { + "__typename": "AuthError" + } + } + } + """ + + #expect { + _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + } throws: { error in + guard case let WarpUsageError.parseFailed(message) = error else { return false } + return message.contains("Unexpected user type") + } + } + + @Test + func missingRequestLimitInfoReturnsParseError() { + let json = """ + { + "data": { + "user": { + "__typename": "UserOutput", + "user": {} + } + } + } + """ + + #expect { + _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + } throws: { error in + guard case let WarpUsageError.parseFailed(message) = error else { return false } + return message.contains("requestLimitInfo") + } + } + + @Test + func invalidRootReturnsParseError() { + let json = """ + [{ "data": {} }] + """ + + #expect { + _ = try WarpUsageFetcher._parseResponseForTesting(Data(json.utf8)) + } throws: { error in + guard case let WarpUsageError.parseFailed(message) = error else { return false } + return message == "Root JSON is not an object." + } + } + + @Test + func toUsageSnapshotOmitsSecondaryWhenNoBonusCredits() { + let source = WarpUsageSnapshot( + requestLimit: 100, + requestsUsed: 10, + nextRefreshTime: Date().addingTimeInterval(3600), + isUnlimited: false, + updatedAt: Date(), + bonusCreditsRemaining: 0, + bonusCreditsTotal: 0, + bonusNextExpiration: nil, + bonusNextExpirationRemaining: 0) + + let snapshot = source.toUsageSnapshot() + #expect(snapshot.secondary == nil) + } + + @Test + func toUsageSnapshotKeepsBonusWindowWhenBonusExists() throws { + let source = WarpUsageSnapshot( + requestLimit: 100, + requestsUsed: 10, + nextRefreshTime: Date().addingTimeInterval(3600), + isUnlimited: false, + updatedAt: Date(), + bonusCreditsRemaining: 0, + bonusCreditsTotal: 20, + bonusNextExpiration: nil, + bonusNextExpirationRemaining: 0) + + let snapshot = source.toUsageSnapshot() + let secondary = try #require(snapshot.secondary) + #expect(secondary.usedPercent == 100) + } + + @Test + func toUsageSnapshotUnlimitedPrimaryDoesNotShowResetDate() throws { + let source = WarpUsageSnapshot( + requestLimit: 0, + requestsUsed: 0, + nextRefreshTime: Date().addingTimeInterval(3600), + isUnlimited: true, + updatedAt: Date(), + bonusCreditsRemaining: 0, + bonusCreditsTotal: 0, + bonusNextExpiration: nil, + bonusNextExpirationRemaining: 0) + + let snapshot = source.toUsageSnapshot() + let primary = try #require(snapshot.primary) + #expect(primary.resetsAt == nil) + #expect(primary.resetDescription == "Unlimited") + } + + @Test + func apiErrorSummaryIncludesPlainTextBodies() { + // Regression: Warp edge returns 429 with a non-JSON body ("Rate exceeded.") when User-Agent is missing/wrong. + let summary = WarpUsageFetcher._apiErrorSummaryForTesting( + statusCode: 429, + data: Data("Rate exceeded.".utf8)) + #expect(summary.contains("Rate exceeded.")) + } +} diff --git a/Tests/CodexBarTests/ZaiAvailabilityTests.swift b/Tests/CodexBarTests/ZaiAvailabilityTests.swift index ec0d40d9f..6ce229589 100644 --- a/Tests/CodexBarTests/ZaiAvailabilityTests.swift +++ b/Tests/CodexBarTests/ZaiAvailabilityTests.swift @@ -29,6 +29,30 @@ struct ZaiAvailabilityTests { #expect(store.isEnabled(.zai) == true) #expect(settings.zaiAPIToken == "zai-test-token") } + + @Test + func enablesZaiWhenTokenExistsInTokenAccounts() throws { + let suite = "ZaiAvailabilityTests-token-accounts" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + settings.addTokenAccount(provider: .zai, label: "primary", token: "zai-token-account") + + let metadata = try #require(ProviderRegistry.shared.metadata[.zai]) + settings.setProviderEnabled(provider: .zai, metadata: metadata, enabled: true) + + #expect(store.isEnabled(.zai) == true) + } } private struct StubZaiTokenStore: ZaiTokenStoring { diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index 8b094ba8b..c4184396a 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -71,10 +71,120 @@ struct ZaiUsageSnapshotTests { #expect(usage.secondary?.resetDescription == "30 days window") #expect(usage.zaiUsage?.tokenLimit?.usage == 100) } + + @Test + func mapsUsageSnapshotWindowsWithMissingFields() { + let reset = Date(timeIntervalSince1970: 123) + let tokenLimit = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: tokenLimit, + timeLimit: nil, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "5 hours window") + #expect(usage.zaiUsage?.tokenLimit?.usage == nil) + } + + @Test + func mapsUsageSnapshotWindowsWithMissingRemainingUsesCurrentValue() { + let reset = Date(timeIntervalSince1970: 123) + let tokenLimit = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: 20, + remaining: nil, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: tokenLimit, + timeLimit: nil, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + } + + @Test + func mapsUsageSnapshotWindowsWithMissingCurrentValueUsesRemaining() { + let reset = Date(timeIntervalSince1970: 123) + let tokenLimit = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: nil, + remaining: 80, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: tokenLimit, + timeLimit: nil, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + } + + @Test + func mapsUsageSnapshotWindowsWithMissingRemainingAndCurrentValueFallsBackToPercentage() { + let reset = Date(timeIntervalSince1970: 123) + let tokenLimit = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: nil, + remaining: nil, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: tokenLimit, + timeLimit: nil, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + } } @Suite struct ZaiUsageParsingTests { + @Test + func emptyBodyReturnsParseFailed() { + #expect { + _ = try ZaiUsageFetcher.parseUsageSnapshot(from: Data()) + } throws: { error in + guard case let ZaiUsageError.parseFailed(message) = error else { return false } + return message == "Empty response body" + } + } + @Test func parsesUsageResponse() throws { let json = """ @@ -117,6 +227,7 @@ struct ZaiUsageParsingTests { #expect(snapshot.planName == "Pro") #expect(snapshot.tokenLimit?.usage == 40_000_000) #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") + #expect(snapshot.tokenLimit?.percentage == 34.0) } @Test @@ -164,6 +275,52 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit == nil) #expect(snapshot.timeLimit == nil) } + + @Test + func parsesNewSchemaWithMissingTokenLimitFields() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 0, + "remaining": 100, + "percentage": 0, + "usageDetails": [ + { "modelCode": "search-prime", "usage": 0 }, + { "modelCode": "web-reader", "usage": 1 }, + { "modelCode": "zread", "usage": 0 } + ] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 1, + "nextResetTime": 1770724088678 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.tokenLimit?.percentage == 1.0) + #expect(snapshot.tokenLimit?.usage == nil) + #expect(snapshot.tokenLimit?.currentValue == nil) + #expect(snapshot.tokenLimit?.remaining == nil) + #expect(snapshot.tokenLimit?.usedPercent == 1.0) + #expect(snapshot.tokenLimit?.windowMinutes == 300) + #expect(snapshot.timeLimit?.usage == 100) + } } @Suite diff --git a/appcast.xml b/appcast.xml index 3f63a89e9..c7bc8f030 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,68 @@ CodexBar + + 0.18.0-beta.3 + Fri, 13 Feb 2026 18:57:54 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 51 + 0.18.0-beta.3 + 14.0 + CodexBar 0.18.0-beta.3 +

Highlights

+
    +
  • Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12!
  • +
  • Claude: harden Claude Code PTY capture for /usage and /status (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320).
  • +
  • New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu!
  • +
  • Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44!
  • +
  • Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs!
  • +
  • CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290).
  • +
+

Claude OAuth & Keychain

+
    +
  • Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts.
  • +
  • Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required.
  • +
  • In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh).
  • +
  • OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back.
  • +
  • Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive.
  • +
+

Provider & Usage Fixes

+
    +
  • Warp: add Warp provider support (credits + add-on credits), configurable via Settings or WARP_API_KEY/WARP_TOKEN (#352). Thanks @Kathie-yu!
  • +
  • Cursor: compute usage against plan.limit rather than breakdown.total to avoid incorrect limit interpretation (#240). Thanks @robinebers!
  • +
  • MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44!
  • +
  • MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan!
  • +
  • Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf!
  • +
  • z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin!
  • +
  • z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment).
  • +
  • Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden!
  • +
  • Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev!
  • +
  • OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07!
  • +
  • Token-account precedence: selected token account env injection now correctly overrides provider config apiKey values in app and CLI environments. Thanks @arvindcr4!
  • +
  • Claude: make Claude CLI probing more resilient by scoping auto-input to the active subcommand and trimming to the latest Usage panel before parsing to avoid false matches from earlier screen fragments (#320).
  • +
+

Menu Bar & UI Behavior

+
    +
  • Prevent fallback-provider loading animation loops (battery/CPU drain when no providers are enabled) (#283). Thanks @vignesh07!
  • +
  • Prevent status overlay rendering for disabled providers while in merged mode (#291). Thanks @Ilakiancs!
  • +
+

CI, Tooling & Test Stability

+
    +
  • Pin SwiftFormat/SwiftLint versions and harden lint installer behavior (version drift + temp-file leak fixes) (#292).
  • +
  • Use more deterministic macOS CI test settings (including non-parallel paths where needed) and align runner/toolchain behavior for stability (#292).
  • +
  • Stabilize PTY command timing tests to reduce CI flakiness (#312).
  • +
  • Upgrade actions/checkout to v6 and actions/github-script to v8 for Node 24 compatibility in upstream-monitor.yml (#290). Thanks @salmanmkc!
  • +
  • Tests: add TaskLocal-based keychain/cache overrides so keychain gating and KeychainCacheStore test stores do not leak across concurrent test execution (#320).
  • +
+

Docs & Maintenance

+
    +
  • Update docs for Claude data fetch behavior and keychain troubleshooting notes.
  • +
  • Update MIT license year.
  • +
+

View full changelog

+]]>
+ +
0.18.0-beta.2 Wed, 21 Jan 2026 08:42:37 +0000 diff --git a/docs/claude.md b/docs/claude.md index 564672dda..22737efd9 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -15,12 +15,22 @@ Claude supports three usage data paths plus local cost usage. Source selection i ### Default selection (debug menu disabled) 1) OAuth API (if Claude CLI credentials include `user:profile` scope). -2) Web API (browser cookies, `sessionKey`), if OAuth missing. -3) CLI PTY (`claude`), if no OAuth and no web session. +2) CLI PTY (`claude`), if OAuth is unavailable or fails. +3) Web API (browser cookies, `sessionKey`), if OAuth + CLI are unavailable or fail. Usage source picker: - Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI). +## Keychain prompt policy (Claude OAuth) +- Preferences → Providers → Claude → Keychain prompt policy. +- Options: + - `Never prompt`: never attempts interactive Claude OAuth Keychain prompts. + - `Only on user action` (default): interactive prompts are reserved for user-initiated repair flows. + - `Always allow prompts`: allows interactive prompts in both user and background flows. +- This setting only affects Claude OAuth Keychain prompting behavior; it does not switch your Claude usage source. +- If Preferences → Advanced → Disable Keychain access is enabled, this policy remains visible but inactive until + Keychain access is re-enabled. + ### Debug selection (debug menu enabled) - The Debug pane can force OAuth / Web / CLI. - Web extras are internal-only (not exposed in the Providers pane). diff --git a/docs/configuration.md b/docs/configuration.md index 020293718..467207988 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,7 +43,7 @@ All provider fields are optional unless noted. - `enabled`: enable/disable provider (defaults to provider default). - `source`: preferred source mode. - `auto|web|cli|oauth|api` - - `auto` uses web where possible, with CLI fallback. + - `auto` uses provider-specific fallback order (see `docs/providers.md`). - `api` uses provider API key flow (when supported). - `apiKey`: raw API token for providers that support direct API usage. - `cookieSource`: cookie selection policy. diff --git a/docs/ollama.md b/docs/ollama.md new file mode 100644 index 000000000..5be112746 --- /dev/null +++ b/docs/ollama.md @@ -0,0 +1,53 @@ +--- +summary: "Ollama provider notes: settings scrape, cookie auth, and Cloud Usage parsing." +read_when: + - Adding or modifying the Ollama provider + - Debugging Ollama cookie import or settings parsing + - Adjusting Ollama menu labels or usage mapping +--- + +# Ollama Provider + +The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage limits for session and weekly windows. + +## Features + +- **Plan badge**: Reads the plan tier (Free/Pro/Max) from the Cloud Usage header. +- **Session + weekly usage**: Parses the percent-used values shown in the usage bars. +- **Reset timestamps**: Uses the `data-time` attribute on the “Resets in …” elements. +- **Browser cookie auth**: No API keys required. + +## Setup + +1. Open **Settings → Providers**. +2. Enable **Ollama**. +3. Leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default). + +### Manual cookie import (optional) + +1. Open `https://ollama.com/settings` in your browser. +2. Copy a `Cookie:` header from the Network tab. +3. Paste it into **Ollama → Cookie source → Manual**. + +## How it works + +- Fetches `https://ollama.com/settings` using browser cookies. +- Parses: + - Plan badge under **Cloud Usage**. + - **Session usage** and **Weekly usage** percentages. + - `data-time` ISO timestamps for reset times. + +## Troubleshooting + +### “No Ollama session cookie found” + +Log in to `https://ollama.com/settings` in Chrome, then refresh in CodexBar. +If your active session is only in Safari (or another browser), use **Cookie source → Manual** and paste a cookie header. + +### “Ollama session cookie expired” + +Sign out and back in at `https://ollama.com/settings`, then refresh. + +### “Could not parse Ollama usage” + +The settings page HTML may have changed. Capture the latest page HTML and update `OllamaUsageParser`. diff --git a/docs/openrouter.md b/docs/openrouter.md new file mode 100644 index 000000000..a0d7985e3 --- /dev/null +++ b/docs/openrouter.md @@ -0,0 +1,56 @@ +# OpenRouter Provider + +[OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint. + +## Authentication + +OpenRouter uses API key authentication. Get your API key from [OpenRouter Settings](https://openrouter.ai/settings/keys). + +### Environment Variable + +Set the `OPENROUTER_API_KEY` environment variable: + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +### Settings + +You can also configure the API key in CodexBar Settings → Providers → OpenRouter. + +## Data Source + +The OpenRouter provider fetches usage data from two API endpoints: + +1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`. + +2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key. + +## Display + +The OpenRouter menu card shows: + +- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used) +- **Balance**: Displayed in the identity section as "Balance: $X.XX" + +## CLI Usage + +```bash +codexbar --provider openrouter +codexbar -p or # alias +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `OPENROUTER_API_KEY` | Your OpenRouter API key (required) | +| `OPENROUTER_API_URL` | Override the base API URL (optional, defaults to `https://openrouter.ai/api/v1`) | +| `OPENROUTER_HTTP_REFERER` | Optional client referer sent as `HTTP-Referer` header | +| `OPENROUTER_X_TITLE` | Optional client title sent as `X-Title` header (defaults to `CodexBar`) | + +## Notes + +- Credit values are cached on OpenRouter's side and may be up to 60 seconds stale +- OpenRouter uses a credit-based billing system where you pre-purchase credits +- Rate limits depend on your credit balance (10+ credits = 1000 free model requests/day) diff --git a/docs/providers.md b/docs/providers.md index d25dfb887..5b6126847 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -19,7 +19,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Provider | Strategies (ordered for auto) | | --- | --- | | Codex | Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`); app uses CLI usage + optional dashboard scrape. | -| Claude | OAuth API (`oauth`) → Web API (`web`) → CLI PTY (`claude`). | +| Claude | App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | | Gemini | OAuth API via Gemini CLI credentials (`api`). | | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → stored WebKit session (`web`). | @@ -34,6 +34,9 @@ until the session is invalid, to avoid repeated Keychain prompts. | Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). | | JetBrains AI | Local XML quota file (`local`). | | Amp | Web settings page via browser cookies (`web`). | +| Warp | API token (config/env) → GraphQL request limits (`api`). | +| Ollama | Web settings page via browser cookies (`web`). | +| OpenRouter | API token (config, overrides env) → credits API (`api`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -44,9 +47,8 @@ until the session is invalid, to avoid repeated Keychain prompts. - Details: `docs/codex.md`. ## Claude -- OAuth API (preferred when CLI credentials exist). -- Web API (browser cookies) fallback when OAuth missing. -- CLI PTY fallback when OAuth + web are unavailable. +- App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). +- CLI Auto: Web API (`web`) → CLI PTY (`claude`). - Local cost usage: scans `~/.config/claude/projects/**/*.jsonl` (last 30 days). - Status: Statuspage.io (Anthropic). - Details: `docs/claude.md`. @@ -121,6 +123,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: AWS Health Dashboard (manual link, no auto-polling). - Details: `docs/kiro.md`. +## Warp +- API token from Settings or `WARP_API_KEY` / `WARP_TOKEN` env var. +- GraphQL credit limits: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`. +- Shows monthly credits usage and next refresh time. +- Status: none yet. +- Details: `docs/warp.md`. + ## Vertex AI - OAuth credentials from `gcloud auth application-default login` (ADC). - Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`. @@ -139,4 +148,19 @@ until the session is invalid, to avoid repeated Keychain prompts. - Parses Amp Free usage from the settings HTML. - Status: none yet. - Details: `docs/amp.md`. + +## Ollama +- Web settings page (`https://ollama.com/settings`) via browser cookies. +- Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps. +- Status: none yet. +- Details: `docs/ollama.md`. + +## OpenRouter +- API token from `~/.codexbar/config.json` (`providerConfig.openrouter.apiKey`) or `OPENROUTER_API_KEY` env var. +- Credits endpoint: `https://openrouter.ai/api/v1/credits` (returns total credits purchased and usage). +- Key info endpoint: `https://openrouter.ai/api/v1/key` (returns rate limit info). +- Override base URL with `OPENROUTER_API_URL` env var. +- Status: `https://status.openrouter.ai` (link only, no auto-polling yet). +- Details: `docs/openrouter.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/ui.md b/docs/ui.md index 73f6fc57a..9c3d13475 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -26,6 +26,10 @@ read_when: ## Preferences notes - Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers. +- Providers → Claude: “Keychain prompt policy” controls Claude OAuth prompt behavior (Never / Only on user action / + Always allow prompts). +- When “Disable Keychain access” is enabled in Advanced, the Claude keychain prompt policy remains visible but is + inactive. ## Widgets (high level) - Widget entries mirror the menu card; detailed pipeline in `docs/widgets.md`. diff --git a/docs/warp.md b/docs/warp.md new file mode 100644 index 000000000..eeb66145e --- /dev/null +++ b/docs/warp.md @@ -0,0 +1,49 @@ +--- +summary: "Warp provider notes: API token setup and request limit parsing." +read_when: + - Adding or modifying the Warp provider + - Debugging Warp API tokens or request limits + - Adjusting Warp usage labels or reset behavior +--- + +# Warp Provider + +The Warp provider reads credit limits from Warp's GraphQL API using an API token. + +## Features + +- **Monthly credits usage**: Shows credits used vs. plan limit. +- **Reset timing**: Displays the next refresh time when available. +- **Token-based auth**: Uses API key stored in Settings or env vars. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Warp** +3. In Warp, open your profile menu → **Settings → Platform → API Keys**, then create a key. +4. Enter the created `wk-...` key in CodexBar. + +Reference guide: `https://docs.warp.dev/reference/cli/api-keys` + +### Environment variables (optional) + +- `WARP_API_KEY` +- `WARP_TOKEN` + +## How it works + +- Endpoint: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo` +- Query: `GetRequestLimitInfo` +- Fields used: `isUnlimited`, `nextRefreshTime`, `requestLimit`, `requestsUsedSinceLastRefresh` (API uses request-named fields for credits) + +If `isUnlimited` is true, the UI shows “Unlimited” and a full remaining bar. + +## Troubleshooting + +### “Missing Warp API key” + +Add a key in **Settings → Providers → Warp**, or set `WARP_API_KEY`. + +### “Warp API error” + +Confirm the token is valid and that your network can reach `app.warp.dev`. diff --git a/version.env b/version.env index cd2fd9f2f..4bad6b853 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.18.0-beta.2-jl.2 +MARKETING_VERSION=0.18.0-beta.3-jl.3 BUILD_NUMBER=51