From 2882485ab8e9b27d2354c762e0c554b5cb054e0e Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Wed, 8 Jul 2026 21:09:45 -0700 Subject: [PATCH 1/5] Add external event hooks for quota and provider state changes Run user-configured external commands when quota/provider events occur, without CodexBar owning any switching logic. Hooks are opt-in and disabled by default. Core (CodexBarCore/Hooks): HookEvent (env vars + stdin JSON payload), HookRule/HooksConfig (matching, provider scoping, quota_low threshold gate, absolute-path requirement), HookRunner (delegates process work to the existing SubprocessRunner: shell-free exec, env injection, stdin, timeout, path validation, redacted logging), and an in-memory HookRateLimiter as a storm backstop. Adds an optional top-level `hooks` section to CodexBarConfig. App: emitHook dispatches fire-and-forget from the existing detection choke points so nothing blocks menu refresh: quota_low, quota_reached, quota_reset, provider_unavailable, provider_recovered, refresh_failed. Account labels respect hidePersonalInfo. Adds a Hooks preferences pane (enable toggle, trust warning, per-rule editing) wired through SettingsStore config mutators. CLI: `codexbar hooks list | enable | disable | test --provider

`, sharing HookRunner with the app. Tests: unit coverage for matching, threshold gating, payload encoding, rate limiting, and the runner; localization catalogs updated for the new UI keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/PreferencesHooksPane.swift | 138 +++++++++++++ Sources/CodexBar/PreferencesSelection.swift | 2 + Sources/CodexBar/PreferencesSidebar.swift | 1 + Sources/CodexBar/PreferencesView.swift | 4 + .../Resources/ar.lproj/Localizable.strings | 18 ++ .../Resources/en.lproj/Localizable.strings | 18 ++ .../Resources/fa.lproj/Localizable.strings | 18 ++ .../Resources/gl.lproj/Localizable.strings | 18 ++ .../Resources/id.lproj/Localizable.strings | 18 ++ .../Resources/it.lproj/Localizable.strings | 18 ++ .../Resources/pl.lproj/Localizable.strings | 18 ++ .../Resources/th.lproj/Localizable.strings | 18 ++ .../Resources/tr.lproj/Localizable.strings | 18 ++ Sources/CodexBar/SettingsStore+Config.swift | 44 ++++ .../SettingsStore+ConfigPersistence.swift | 2 +- Sources/CodexBar/UsageStore+Hooks.swift | 89 ++++++++ .../CodexBar/UsageStore+PlanUtilization.swift | 13 ++ .../CodexBar/UsageStore+QuotaWarnings.swift | 39 ++++ Sources/CodexBar/UsageStore+Refresh.swift | 1 + Sources/CodexBar/UsageStore.swift | 37 +--- Sources/CodexBarCLI/CLIEntry.swift | 32 +++ Sources/CodexBarCLI/CLIHelp.swift | 30 +++ Sources/CodexBarCLI/CLIHooksCommand.swift | 190 ++++++++++++++++++ Sources/CodexBarCLI/CLIIO.swift | 2 + .../CodexBarCore/Config/CodexBarConfig.swift | 12 +- Sources/CodexBarCore/Hooks/HookEvent.swift | 97 +++++++++ .../CodexBarCore/Hooks/HookRateLimiter.swift | 37 ++++ Sources/CodexBarCore/Hooks/HookRule.swift | 91 +++++++++ Sources/CodexBarCore/Hooks/HookRunner.swift | 76 +++++++ .../CodexBarCore/Logging/LogCategories.swift | 1 + .../LocalizationLanguageCatalogTests.swift | 3 + .../PreferencesPaneSmokeTests.swift | 1 + TestsLinux/HooksTests.swift | 133 ++++++++++++ 33 files changed, 1204 insertions(+), 33 deletions(-) create mode 100644 Sources/CodexBar/PreferencesHooksPane.swift create mode 100644 Sources/CodexBar/UsageStore+Hooks.swift create mode 100644 Sources/CodexBarCLI/CLIHooksCommand.swift create mode 100644 Sources/CodexBarCore/Hooks/HookEvent.swift create mode 100644 Sources/CodexBarCore/Hooks/HookRateLimiter.swift create mode 100644 Sources/CodexBarCore/Hooks/HookRule.swift create mode 100644 Sources/CodexBarCore/Hooks/HookRunner.swift create mode 100644 TestsLinux/HooksTests.swift diff --git a/Sources/CodexBar/PreferencesHooksPane.swift b/Sources/CodexBar/PreferencesHooksPane.swift new file mode 100644 index 0000000000..88a8486c81 --- /dev/null +++ b/Sources/CodexBar/PreferencesHooksPane.swift @@ -0,0 +1,138 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct HooksPane: View { + @Bindable var settings: SettingsStore + + var body: some View { + Form { + Section { + Toggle(isOn: self.enabledBinding) { + SettingsRowLabel(L("hooks_enable_title"), subtitle: L("hooks_enable_subtitle")) + } + Label(L("hooks_trust_warning"), systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } header: { + Text(L("tab_hooks")) + } + + Section { + if self.settings.hookRules.isEmpty { + Text(L("hooks_empty")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(self.settings.hookRules) { rule in + HookRuleRow( + rule: self.binding(for: rule), + onDelete: { self.settings.removeHookRule(id: rule.id) }) + } + } + + Button { + self.settings.addHookRule(HookRule(event: .quotaReached, executable: "")) + } label: { + Label(L("hooks_add_rule"), systemImage: "plus") + } + } header: { + Text(L("hooks_rules_header")) + } + } + .formStyle(.grouped) + } + + private var enabledBinding: Binding { + Binding( + get: { self.settings.hooksEnabled }, + set: { self.settings.setHooksEnabled($0) }) + } + + private func binding(for rule: HookRule) -> Binding { + Binding( + get: { self.settings.hookRules.first(where: { $0.id == rule.id }) ?? rule }, + set: { self.settings.updateHookRule($0) }) + } +} + +@MainActor +private struct HookRuleRow: View { + @Binding var rule: HookRule + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Toggle(L("hooks_rule_enabled"), isOn: self.$rule.enabled) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + + Picker(L("hooks_event"), selection: self.$rule.event) { + ForEach(HookEventType.allCases, id: \.self) { event in + Text(event.rawValue).tag(event) + } + } + .labelsHidden() + + Picker(L("hooks_provider"), selection: self.providerBinding) { + Text(L("hooks_any_provider")).tag(String?.none) + ForEach(UsageProvider.allCases, id: \.self) { provider in + Text(ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName) + .tag(String?.some(provider.rawValue)) + } + } + .labelsHidden() + + Spacer() + + Button(role: .destructive, action: self.onDelete) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel(L("hooks_delete_rule")) + } + + if self.rule.event == .quotaLow { + HStack { + Text(L("hooks_threshold")) + .foregroundStyle(.secondary) + TextField(L("hooks_threshold_placeholder"), value: self.thresholdPercentBinding, format: .number) + .frame(width: 60) + Text(verbatim: "%") + .foregroundStyle(.secondary) + } + .font(.caption) + } + + TextField(L("hooks_executable_placeholder"), text: self.$rule.executable) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + + TextField(L("hooks_arguments_placeholder"), text: self.argumentsBinding) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + } + .padding(.vertical, 4) + } + + private var providerBinding: Binding { + Binding(get: { self.rule.provider }, set: { self.rule.provider = $0 }) + } + + /// Threshold stored as a 0...1 fraction, edited as a 0...100 percentage. + private var thresholdPercentBinding: Binding { + Binding( + get: { self.rule.threshold.map { $0 * 100 } }, + set: { self.rule.threshold = $0.map { min(max($0, 0), 100) / 100 } }) + } + + /// Whitespace-joined arguments. Simple split by spaces; adequate for v1. + private var argumentsBinding: Binding { + Binding( + get: { self.rule.arguments.joined(separator: " ") }, + set: { self.rule.arguments = $0.split(separator: " ").map(String.init) }) + } +} diff --git a/Sources/CodexBar/PreferencesSelection.swift b/Sources/CodexBar/PreferencesSelection.swift index 19695ce7ff..d69d9db48e 100644 --- a/Sources/CodexBar/PreferencesSelection.swift +++ b/Sources/CodexBar/PreferencesSelection.swift @@ -9,6 +9,7 @@ extension SettingsPane { case .general: "general" case .display: "display" case .advanced: "advanced" + case .hooks: "hooks" case .about: "about" case .debug: "debug" case let .provider(provider): "provider:\(provider.rawValue)" @@ -20,6 +21,7 @@ extension SettingsPane { case "general": self = .general case "display": self = .display case "advanced": self = .advanced + case "hooks": self = .hooks case "about": self = .about case "debug": self = .debug default: diff --git a/Sources/CodexBar/PreferencesSidebar.swift b/Sources/CodexBar/PreferencesSidebar.swift index 0e34e95aeb..2696ee5acf 100644 --- a/Sources/CodexBar/PreferencesSidebar.swift +++ b/Sources/CodexBar/PreferencesSidebar.swift @@ -35,6 +35,7 @@ struct SettingsSidebarView: View { SettingsSidebarPaneRow(pane: .general, systemImage: "gearshape.fill", color: .gray) SettingsSidebarPaneRow(pane: .display, systemImage: "menubar.rectangle", color: .blue) SettingsSidebarPaneRow(pane: .advanced, systemImage: "slider.horizontal.3", color: .purple) + SettingsSidebarPaneRow(pane: .hooks, systemImage: "bolt.horizontal.circle.fill", color: .orange) SettingsSidebarAboutRow() if self.settings.debugMenuEnabled { SettingsSidebarPaneRow(pane: .debug, systemImage: "ladybug.fill", color: .red) diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 3fe51e53c0..89eb697603 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -7,6 +7,7 @@ enum SettingsPane: Hashable { case general case display case advanced + case hooks case about case debug case provider(UsageProvider) @@ -23,6 +24,7 @@ enum SettingsPane: Hashable { case .general: L("tab_general") case .display: L("tab_display") case .advanced: L("tab_advanced") + case .hooks: L("tab_hooks") case .about: L("tab_about") case .debug: L("tab_debug") case let .provider(provider): @@ -126,6 +128,8 @@ struct PreferencesView: View { DisplayPane(settings: self.settings, store: self.store) case .advanced: AdvancedPane(settings: self.settings, store: self.store) + case .hooks: + HooksPane(settings: self.settings) case .about: AboutPane(updater: self.updater) case .debug: diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 788e792f41..e8ab9551c9 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -478,7 +478,25 @@ "tab_providers" = "مقدمو الخدمات"; "tab_display" = "العرض"; "tab_advanced" = "متقدمة"; +"tab_hooks" = "الخطافات"; "tab_about" = "حول"; + +/* Hooks Pane */ +"hooks_enable_title" = "تفعيل الخطافات"; +"hooks_enable_subtitle" = "تشغيل أوامر خارجية عند وقوع أحداث الحصة أو المزود."; +"hooks_trust_warning" = "يمكن للخطافات تنفيذ أوامر محلية على جهاز Mac. اضبط فقط الأوامر التي تثق بها."; +"hooks_rules_header" = "القواعد"; +"hooks_empty" = "لا توجد خطافات مُهيأة."; +"hooks_add_rule" = "إضافة قاعدة"; +"hooks_delete_rule" = "حذف القاعدة"; +"hooks_rule_enabled" = "مُفعّل"; +"hooks_event" = "الحدث"; +"hooks_provider" = "المزود"; +"hooks_any_provider" = "أي مزود"; +"hooks_threshold" = "التشغيل عند الاستخدام ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "الوسائط (مفصولة بمسافات)"; "tab_debug" = "تصحيح الأخطاء"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 4b5b472f22..9221aba408 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -478,9 +478,27 @@ "tab_providers" = "Providers"; "tab_display" = "Display"; "tab_advanced" = "Advanced"; +"tab_hooks" = "Hooks"; "tab_about" = "About"; "tab_debug" = "Debug"; +/* Hooks Pane */ +"hooks_enable_title" = "Enable hooks"; +"hooks_enable_subtitle" = "Run external commands when quota or provider events occur."; +"hooks_trust_warning" = "Hooks can execute local commands on your Mac. Only configure commands you trust."; +"hooks_rules_header" = "Rules"; +"hooks_empty" = "No hooks configured."; +"hooks_add_rule" = "Add rule"; +"hooks_delete_rule" = "Delete rule"; +"hooks_rule_enabled" = "Enabled"; +"hooks_event" = "Event"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Any provider"; +"hooks_threshold" = "Fire at usage ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Arguments (space-separated)"; + /* Providers Pane */ "select_a_provider" = "Select a provider"; "cancel" = "Cancel"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e62f320240..3db4888d40 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -478,7 +478,25 @@ "tab_providers" = "ارائه دهندگان"; "tab_display" = "نمایش"; "tab_advanced" = "پیشرفته"; +"tab_hooks" = "قلاب‌ها"; "tab_about" = "درباره"; + +/* Hooks Pane */ +"hooks_enable_title" = "فعال‌سازی قلاب‌ها"; +"hooks_enable_subtitle" = "اجرای دستورهای خارجی هنگام رخ‌دادن رویدادهای سهمیه یا ارائه‌دهنده."; +"hooks_trust_warning" = "قلاب‌ها می‌توانند دستورهای محلی را روی مک شما اجرا کنند. فقط دستورهایی را تنظیم کنید که به آن‌ها اعتماد دارید."; +"hooks_rules_header" = "قوانین"; +"hooks_empty" = "هیچ قلابی پیکربندی نشده است."; +"hooks_add_rule" = "افزودن قانون"; +"hooks_delete_rule" = "حذف قانون"; +"hooks_rule_enabled" = "فعال"; +"hooks_event" = "رویداد"; +"hooks_provider" = "ارائه‌دهنده"; +"hooks_any_provider" = "هر ارائه‌دهنده"; +"hooks_threshold" = "اجرا در مصرف ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "آرگومان‌ها (جدا شده با فاصله)"; "tab_debug" = "اشکال زدایی"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index c716d497ee..996edbf41d 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -464,6 +464,24 @@ "tab_providers" = "Provedores"; "tab_display" = "Pantalla"; "tab_advanced" = "Avanzado"; +"tab_hooks" = "Ganchos"; + +/* Hooks Pane */ +"hooks_enable_title" = "Activar ganchos"; +"hooks_enable_subtitle" = "Executa comandos externos cando se producen eventos de cota ou provedor."; +"hooks_trust_warning" = "Os ganchos poden executar comandos locais no teu Mac. Configura só comandos nos que confíes."; +"hooks_rules_header" = "Regras"; +"hooks_empty" = "Non hai ganchos configurados."; +"hooks_add_rule" = "Engadir regra"; +"hooks_delete_rule" = "Eliminar regra"; +"hooks_rule_enabled" = "Activado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provedor"; +"hooks_any_provider" = "Calquera provedor"; +"hooks_threshold" = "Activar cun uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos (separados por espazos)"; "tab_about" = "Acerca de"; "tab_debug" = "Depuración"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 368a6480d3..211c973742 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -480,6 +480,24 @@ "tab_providers" = "Penyedia"; "tab_display" = "Tampilan"; "tab_advanced" = "Lanjutan"; +"tab_hooks" = "Hook"; + +/* Hooks Pane */ +"hooks_enable_title" = "Aktifkan hook"; +"hooks_enable_subtitle" = "Jalankan perintah eksternal saat terjadi peristiwa kuota atau penyedia."; +"hooks_trust_warning" = "Hook dapat menjalankan perintah lokal di Mac Anda. Hanya konfigurasikan perintah yang Anda percayai."; +"hooks_rules_header" = "Aturan"; +"hooks_empty" = "Belum ada hook yang dikonfigurasi."; +"hooks_add_rule" = "Tambah aturan"; +"hooks_delete_rule" = "Hapus aturan"; +"hooks_rule_enabled" = "Aktif"; +"hooks_event" = "Peristiwa"; +"hooks_provider" = "Penyedia"; +"hooks_any_provider" = "Penyedia apa pun"; +"hooks_threshold" = "Picu saat penggunaan ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumen (dipisahkan spasi)"; "tab_about" = "Tentang"; "tab_debug" = "Debug"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 32ca01b05b..64b4a9475e 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -480,6 +480,24 @@ "tab_providers" = "Provider"; "tab_display" = "Aspetto"; "tab_advanced" = "Avanzate"; +"tab_hooks" = "Hook"; + +/* Hooks Pane */ +"hooks_enable_title" = "Abilita hook"; +"hooks_enable_subtitle" = "Esegui comandi esterni al verificarsi di eventi di quota o provider."; +"hooks_trust_warning" = "Gli hook possono eseguire comandi locali sul tuo Mac. Configura solo comandi di cui ti fidi."; +"hooks_rules_header" = "Regole"; +"hooks_empty" = "Nessun hook configurato."; +"hooks_add_rule" = "Aggiungi regola"; +"hooks_delete_rule" = "Elimina regola"; +"hooks_rule_enabled" = "Abilitato"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Qualsiasi provider"; +"hooks_threshold" = "Attiva a utilizzo ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argomenti (separati da spazi)"; "tab_about" = "Informazioni"; "tab_debug" = "Diagnostica"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 09c348868d..50775be366 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -480,6 +480,24 @@ "tab_providers" = "Dostawcy"; "tab_display" = "Wygląd"; "tab_advanced" = "Zaawansowane"; +"tab_hooks" = "Haki"; + +/* Hooks Pane */ +"hooks_enable_title" = "Włącz haki"; +"hooks_enable_subtitle" = "Uruchamiaj polecenia zewnętrzne, gdy wystąpią zdarzenia limitu lub dostawcy."; +"hooks_trust_warning" = "Haki mogą uruchamiać lokalne polecenia na Twoim Macu. Konfiguruj tylko polecenia, którym ufasz."; +"hooks_rules_header" = "Reguły"; +"hooks_empty" = "Nie skonfigurowano haków."; +"hooks_add_rule" = "Dodaj regułę"; +"hooks_delete_rule" = "Usuń regułę"; +"hooks_rule_enabled" = "Włączone"; +"hooks_event" = "Zdarzenie"; +"hooks_provider" = "Dostawca"; +"hooks_any_provider" = "Dowolny dostawca"; +"hooks_threshold" = "Uruchom przy użyciu ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumenty (oddzielone spacjami)"; "tab_about" = "O aplikacji"; "tab_debug" = "Debugowanie"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index f537c2d1b9..e333f49dea 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -478,7 +478,25 @@ "tab_providers" = "ผู้ให้บริการ"; "tab_display" = "แสดง"; "tab_advanced" = "ขั้นสูง"; +"tab_hooks" = "ฮุก"; "tab_about" = "เกี่ยวกับ"; + +/* Hooks Pane */ +"hooks_enable_title" = "เปิดใช้งานฮุก"; +"hooks_enable_subtitle" = "เรียกใช้คำสั่งภายนอกเมื่อเกิดเหตุการณ์โควตาหรือผู้ให้บริการ"; +"hooks_trust_warning" = "ฮุกสามารถเรียกใช้คำสั่งในเครื่อง Mac ของคุณได้ ตั้งค่าเฉพาะคำสั่งที่คุณเชื่อถือเท่านั้น"; +"hooks_rules_header" = "กฎ"; +"hooks_empty" = "ยังไม่ได้ตั้งค่าฮุก"; +"hooks_add_rule" = "เพิ่มกฎ"; +"hooks_delete_rule" = "ลบกฎ"; +"hooks_rule_enabled" = "เปิดใช้งาน"; +"hooks_event" = "เหตุการณ์"; +"hooks_provider" = "ผู้ให้บริการ"; +"hooks_any_provider" = "ผู้ให้บริการใดก็ได้"; +"hooks_threshold" = "เรียกใช้เมื่อการใช้งาน ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "อาร์กิวเมนต์ (คั่นด้วยช่องว่าง)"; "tab_debug" = "แก้ไขข้อบกพร่อง"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 308b2740a1..374fcfebad 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -478,6 +478,24 @@ "tab_providers" = "Sağlayıcılar"; "tab_display" = "Görünüm"; "tab_advanced" = "Gelişmiş"; +"tab_hooks" = "Kancalar"; + +/* Hooks Pane */ +"hooks_enable_title" = "Kancaları etkinleştir"; +"hooks_enable_subtitle" = "Kota veya sağlayıcı olayları gerçekleştiğinde harici komutlar çalıştır."; +"hooks_trust_warning" = "Kancalar Mac'inizde yerel komutlar çalıştırabilir. Yalnızca güvendiğiniz komutları yapılandırın."; +"hooks_rules_header" = "Kurallar"; +"hooks_empty" = "Yapılandırılmış kanca yok."; +"hooks_add_rule" = "Kural ekle"; +"hooks_delete_rule" = "Kuralı sil"; +"hooks_rule_enabled" = "Etkin"; +"hooks_event" = "Olay"; +"hooks_provider" = "Sağlayıcı"; +"hooks_any_provider" = "Herhangi bir sağlayıcı"; +"hooks_threshold" = "Şu kullanımda tetikle ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argümanlar (boşlukla ayrılmış)"; "tab_about" = "Hakkında"; "tab_debug" = "Hata Ayıklama"; diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index e4ef5d2aa3..24f8e01a2d 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -84,6 +84,50 @@ extension SettingsStore { } } + // MARK: - Hooks + + var hooksConfig: HooksConfig { + self.configSnapshot.hooks ?? HooksConfig() + } + + var hooksEnabled: Bool { + self.hooksConfig.enabled + } + + var hookRules: [HookRule] { + self.hooksConfig.events + } + + func setHooksEnabled(_ enabled: Bool) { + self.updateHooks { $0.enabled = enabled } + } + + func addHookRule(_ rule: HookRule) { + self.updateHooks { $0.events.append(rule) } + } + + func updateHookRule(_ rule: HookRule) { + self.updateHooks { config in + if let index = config.events.firstIndex(where: { $0.id == rule.id }) { + config.events[index] = rule + } + } + } + + func removeHookRule(id: String) { + self.updateHooks { config in + config.events.removeAll { $0.id == id } + } + } + + private func updateHooks(_ mutate: (inout HooksConfig) -> Void) { + self.updateConfig(reason: "hooks") { config in + var hooks = config.hooks ?? HooksConfig() + mutate(&hooks) + config.hooks = (hooks.enabled || !hooks.events.isEmpty) ? hooks : nil + } + } + var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { get { Dictionary(uniqueKeysWithValues: self.configSnapshot.providers.compactMap { entry in diff --git a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift index ca7edfaa57..a974540055 100644 --- a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift +++ b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift @@ -34,7 +34,7 @@ private struct ConfigChangeContext { } extension SettingsStore { - private func updateConfig(reason: String, mutate: (inout CodexBarConfig) -> Void) { + func updateConfig(reason: String, mutate: (inout CodexBarConfig) -> Void) { guard !self.configLoading else { return } var config = self.config mutate(&config) diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift new file mode 100644 index 0000000000..08600f64fa --- /dev/null +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + /// Builds a `HookEvent` and dispatches it to any matching external hooks. + /// + /// Fire-and-forget: the actual process runs on a detached task so nothing + /// blocks the menu-bar refresh. No-op unless the user enabled hooks. + /// `usagePercent` is a 0...1 fraction. + func emitHook( + _ type: HookEventType, + provider: UsageProvider, + window: String? = nil, + usagePercent: Double? = nil, + resetAt: Date? = nil, + status: String? = nil, + accountDisplayName: String? = nil) + { + guard let hooks = self.settings.config.hooks, hooks.enabled else { return } + + let event = HookEvent( + event: type, + provider: provider.rawValue, + account: accountDisplayName, + window: window, + usagePercent: usagePercent, + resetAt: resetAt, + status: status, + timestamp: Date()) + + let limiter = self.hookRateLimiter + let environment = self.environmentBase + Task.detached(priority: .utility) { + await HookRunner.dispatch( + event: event, + config: hooks, + rateLimiter: limiter, + baseEnvironment: environment) + } + } + + func emitQuotaReachedHook( + provider: UsageProvider, + sessionWindow: (window: RateWindow, source: SessionQuotaWindowSource), + snapshot: UsageSnapshot) + { + self.emitHook( + .quotaReached, + provider: provider, + window: QuotaWarningWindow.session.displayName, + usagePercent: sessionWindow.window.usedPercent / 100, + resetAt: sessionWindow.window.resetsAt, + accountDisplayName: self.hookAccountDisplayName(provider: provider, snapshot: snapshot)) + } + + /// Emits `provider_unavailable` / `provider_recovered` on genuine outage + /// transitions. `.unknown` (transient/first fetch) and `.maintenance` never + /// flip the tracked state, so a hiccuped status probe cannot fire a hook. + func emitProviderStatusHooks(provider: UsageProvider, indicator: ProviderStatusIndicator) { + let isOutage: Bool + switch indicator { + case .minor, .major, .critical: + isOutage = true + case .none: + isOutage = false + case .maintenance, .unknown: + return + } + + let wasOutage = self.providerStatusHadIssue[provider] ?? false + if isOutage, !wasOutage { + self.providerStatusHadIssue[provider] = true + self.emitHook(.providerUnavailable, provider: provider, status: indicator.rawValue) + } else if !isOutage, wasOutage { + self.providerStatusHadIssue[provider] = false + self.emitHook(.providerRecovered, provider: provider, status: indicator.rawValue) + } + } + + /// Account label for a hook payload, redacted when the user hides personal info. + func hookAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard !self.settings.hidePersonalInfo else { return nil } + let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let account, !account.isEmpty else { return nil } + return account + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index b7d400e9cd..9c239b5e54 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -513,6 +513,7 @@ extension UsageStore { "usedPercent": String(format: "%.2f", currentUsed), "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), ]) + let hookAccountLabel = self.settings.hidePersonalInfo ? nil : accountLabel switch descriptor.seriesName { case .session: let event = SessionLimitResetEvent( @@ -521,6 +522,12 @@ extension UsageStore { accountLabel: accountLabel, usedPercent: currentUsed) NotificationCenter.default.post(name: .codexbarSessionLimitReset, object: event) + self.emitHook( + .quotaReset, + provider: context.provider, + window: QuotaWarningWindow.session.displayName, + usagePercent: currentUsed / 100, + accountDisplayName: hookAccountLabel) case .weekly: let event = WeeklyLimitResetEvent( provider: context.provider, @@ -528,6 +535,12 @@ extension UsageStore { accountLabel: accountLabel, usedPercent: currentUsed) NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event) + self.emitHook( + .quotaReset, + provider: context.provider, + window: QuotaWarningWindow.weekly.displayName, + usagePercent: currentUsed / 100, + accountDisplayName: hookAccountLabel) default: return } diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index 646d04c9a6..b3a58a057f 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -1,6 +1,38 @@ import CodexBarCore import Foundation +extension UsageStore { + enum SessionQuotaWindowSource: String { + case primary + case copilotSecondaryFallback + case zaiTertiary + case antigravityQuotaSummary + case antigravityLegacy + } + + struct QuotaWarningStateKey: Hashable { + let provider: UsageProvider + let window: QuotaWarningWindow + /// Distinguishes independent extra rate windows that share a provider/window lane + /// (e.g. multiple `claude-weekly-scoped-*` windows) so their fired-threshold state + /// does not clobber each other or the primary session/weekly lanes. `nil` for the + /// primary session and weekly lanes. + let windowID: String? + + init(provider: UsageProvider, window: QuotaWarningWindow, windowID: String? = nil) { + self.provider = provider + self.window = window + self.windowID = windowID + } + } + + struct QuotaWarningState { + var lastRemaining: Double? + var firedThresholds: Set = [] + var source: SessionQuotaWindowSource? + } +} + @MainActor extension UsageStore { func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { @@ -143,6 +175,13 @@ extension UsageStore { windowID: windowID, windowDisplayLabel: windowDisplayLabel), provider: provider) + self.emitHook( + .quotaLow, + provider: provider, + window: windowDisplayLabel ?? window.displayName, + usagePercent: rateWindow.usedPercent / 100, + resetAt: rateWindow.resetsAt, + accountDisplayName: accountDisplayName) } state.lastRemaining = currentRemaining diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index e52d32cbb3..faaf15737f 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -679,6 +679,7 @@ extension UsageStore { if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) } + self.emitHook(.refreshFailed, provider: provider, status: error.localizedDescription) } else { self.errors[provider] = nil } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b58f91e58e..7cf8ddea5e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -263,6 +263,8 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] + @ObservationIgnored let hookRateLimiter = HookRateLimiter() + @ObservationIgnored var providerStatusHadIssue: [UsageProvider: Bool] = [:] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] @@ -851,36 +853,6 @@ final class UsageStore { self.resetBoundaryRefreshTask?.cancel() } - enum SessionQuotaWindowSource: String { - case primary - case copilotSecondaryFallback - case zaiTertiary - case antigravityQuotaSummary - case antigravityLegacy - } - - struct QuotaWarningStateKey: Hashable { - let provider: UsageProvider - let window: QuotaWarningWindow - /// Distinguishes independent extra rate windows that share a provider/window lane - /// (e.g. multiple `claude-weekly-scoped-*` windows) so their fired-threshold state - /// does not clobber each other or the primary session/weekly lanes. `nil` for the - /// primary session and weekly lanes. - let windowID: String? - - init(provider: UsageProvider, window: QuotaWarningWindow, windowID: String? = nil) { - self.provider = provider - self.window = window - self.windowID = windowID - } - } - - struct QuotaWarningState { - var lastRemaining: Double? - var firedThresholds: Set = [] - var source: SessionQuotaWindowSource? - } - func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) { self.sessionQuotaNotifier.postQuotaWarning( event: event, @@ -945,6 +917,7 @@ final class UsageStore { let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) self.sessionQuotaNotifier.post(transition: .depleted, provider: provider, badge: nil) + self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) } return } @@ -973,6 +946,9 @@ final class UsageStore { self.sessionQuotaLogger.info(message) self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) + if transition == .depleted { + self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) + } } func refreshProviderStatus(_ provider: UsageProvider) async { @@ -1000,6 +976,7 @@ final class UsageStore { if let components { self.statusComponents[provider] = components } + self.emitProviderStatusHooks(provider: provider, indicator: status.indicator) } } catch { self.recordStartupConnectivityRetryableFailure(error) diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 37a5d6a105..80f916eb3e 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -66,6 +66,8 @@ enum CodexBarCLI { self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: false) case ["config", "set-api-key"]: self.runConfigSetAPIKey(invocation.parsedValues) + case let path where path.first == "hooks": + await self.runHooks(path: path, values: invocation.parsedValues) case ["cache", "clear"]: self.runCacheClear(invocation.parsedValues) case ["diagnose"]: @@ -100,6 +102,8 @@ enum CodexBarCLI { let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) let cacheSignature = CommandSignature.describe(CacheOptions()) let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) + let hooksSignature = CommandSignature.describe(HooksOptions()) + let hooksTestSignature = CommandSignature.describe(HooksTestOptions()) return [ CommandDescriptor( @@ -178,6 +182,34 @@ enum CodexBarCLI { signature: configSetAPIKeySignature), ], defaultSubcommandName: "validate"), + CommandDescriptor( + name: "hooks", + abstract: "Run external commands on quota/provider events", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "list", + abstract: "List configured hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "enable", + abstract: "Enable hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "disable", + abstract: "Disable hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "test", + abstract: "Fire matching hooks for an event", + discussion: nil, + signature: hooksTestSignature), + ], + defaultSubcommandName: "list"), CommandDescriptor( name: "cache", abstract: "Cache management", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 8b299dc1ef..1b1f51090a 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -232,6 +232,33 @@ extension CodexBarCLI { """ } + static func hooksHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar hooks list [--format text|json] [--pretty] + codexbar hooks enable + codexbar hooks disable + codexbar hooks test --provider + + Description: + Run external commands when quota/provider events occur. Rules are stored in the + shared config file and are disabled by default. Events: + quota_low, quota_reached, quota_reset, provider_unavailable, provider_recovered, + refresh_failed. + + Commands run directly (no shell), receive event metadata via CODEXBAR_* environment + variables and a JSON payload on stdin, and are timed out. Only configure commands you trust. + + Examples: + codexbar hooks list + codexbar hooks enable + codexbar hooks test quota_reached --provider codex + codexbar hooks test quota_low --provider claude + """ + } + static func diagnoseHelp(version: String) -> String { """ CodexBar \(version) @@ -292,6 +319,8 @@ extension CodexBarCLI { codexbar config set-api-key --provider (--api-key |--stdin) codexbar config set-api-key --provider zai --stdin --usage-scope team --organization-id --workspace-id + codexbar hooks [--format text|json] [--pretty] + codexbar hooks test --provider codexbar cache clear <--cookies|--cost|--all> [--provider ] codexbar diagnose --provider --format json [--redact] [--output ] [--pretty] @@ -316,6 +345,7 @@ extension CodexBarCLI { codexbar config validate --format json --pretty codexbar config enable --provider grok codexbar config set-api-key --provider elevenlabs --stdin + codexbar hooks test quota_reached --provider codex codexbar cache clear --cookies codexbar diagnose --provider minimax --format json --redact --output diagnostic.json codexbar diagnose --provider minimax --format json --pretty diff --git a/Sources/CodexBarCLI/CLIHooksCommand.swift b/Sources/CodexBarCLI/CLIHooksCommand.swift new file mode 100644 index 0000000000..ad668ab765 --- /dev/null +++ b/Sources/CodexBarCLI/CLIHooksCommand.swift @@ -0,0 +1,190 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runHooks(path: [String], values: ParsedValues) async { + switch path { + case ["hooks", "list"]: + self.runHooksList(values) + case ["hooks", "enable"]: + self.runHooksSetEnabled(values, enabled: true) + case ["hooks", "disable"]: + self.runHooksSetEnabled(values, enabled: false) + case ["hooks", "test"]: + await self.runHooksTest(values) + default: + self.exit( + code: .failure, + message: "Unknown command", + output: CLIOutputPreferences.from(values: values), + kind: .args) + } + } + + static func runHooksList(_ values: ParsedValues) { + let output = CLIOutputPreferences.from(values: values) + let hooks = Self.loadConfig(output: output).hooks ?? HooksConfig() + + switch output.format { + case .text: + print("Hooks: \(hooks.enabled ? "enabled" : "disabled")") + if hooks.events.isEmpty { + print("No rules configured.") + } else { + for rule in hooks.events { + let provider = rule.provider ?? "any" + let state = rule.enabled ? "on" : "off" + let command = ([rule.executable] + rule.arguments).joined(separator: " ") + print("[\(state)] \(rule.event.rawValue) provider=\(provider): \(command)") + } + } + case .json: + Self.printJSON(hooks, pretty: output.pretty) + } + + Self.exit(code: .success, output: output, kind: .config) + } + + static func runHooksSetEnabled(_ values: ParsedValues, enabled: Bool) { + let output = CLIOutputPreferences.from(values: values) + let store = CodexBarConfigStore() + var config = Self.loadConfig(output: output) + var hooks = config.hooks ?? HooksConfig() + hooks.enabled = enabled + config.hooks = hooks + + do { + try store.save(config) + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .config) + } + + switch output.format { + case .text: + print("Hooks: \(enabled ? "enabled" : "disabled")") + case .json: + Self.printJSON(hooks, pretty: output.pretty) + } + + Self.exit(code: .success, output: output, kind: .config) + } + + static func runHooksTest(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + + guard let rawEvent = values.positional.first, + let eventType = HookEventType(rawValue: rawEvent) + else { + let names = HookEventType.allCases.map(\.rawValue).joined(separator: ", ") + Self.exit( + code: .failure, + message: "Unknown or missing event. Use one of: \(names).", + output: output, + kind: .args) + } + + guard let rawProvider = values.options["provider"]?.last, + let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] + else { + Self.exit( + code: .failure, + message: "Unknown or missing provider. Use --provider .", + output: output, + kind: .args) + } + + let event = Self.sampleHookEvent(type: eventType, provider: provider.rawValue) + let hooks = Self.loadConfig(output: output).hooks ?? HooksConfig() + let rules = hooks.matchingRules(for: event) + + guard !rules.isEmpty else { + Self.exit( + code: .failure, + message: hooks.enabled + ? "No hook rule matches \(eventType.rawValue) for \(provider.rawValue)." + : "Hooks are disabled.", + output: output, + kind: .config) + } + + var failures = 0 + for rule in rules { + do { + let result = try await HookRunner.run(rule: rule, event: event) + let stdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + print("ran \(rule.executable): OK\(stdout.isEmpty ? "" : " — \(stdout)")") + } catch { + failures += 1 + Self.writeStderr("ran \(rule.executable): \(error.localizedDescription)\n") + } + } + + Self.exit(code: failures == 0 ? .success : .failure, output: output, kind: .runtime) + } + + /// A representative event for `hooks test`: quota events report high usage so a + /// thresholded `quota_low` rule fires; reset reports empty usage. + private static func sampleHookEvent(type: HookEventType, provider: String) -> HookEvent { + let usagePercent: Double? + let status: String? + switch type { + case .quotaLow, .quotaReached: + usagePercent = 0.95 + status = nil + case .quotaReset: + usagePercent = 0 + status = nil + case .providerUnavailable: + usagePercent = nil + status = "major" + case .providerRecovered: + usagePercent = nil + status = "none" + case .refreshFailed: + usagePercent = nil + status = "test failure" + } + return HookEvent( + event: type, + provider: provider, + window: type == .quotaReached || type == .quotaLow || type == .quotaReset ? "session" : nil, + usagePercent: usagePercent, + status: status, + timestamp: Date()) + } +} + +struct HooksOptions: CommanderParsable { + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "Emit JSON") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false +} + +struct HooksTestOptions: CommanderParsable { + @Argument(help: "Event name (e.g. quota_reached)") + var event: String = "" + + @Option(name: .long("provider"), help: ProviderHelp.optionHelp) + var provider: String? + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "Emit JSON") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false +} diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 0dd03065a4..c0a70dffea 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -37,6 +37,8 @@ extension CodexBarCLI { print(Self.serveHelp(version: version)) case "config", "validate", "dump": print(Self.configHelp(version: version)) + case "hooks": + print(Self.hooksHelp(version: version)) case "cache", "clear": print(Self.cacheHelp(version: version)) case "diagnose": diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 515f488fef..bceefd14df 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -5,10 +5,17 @@ public struct CodexBarConfig: Codable, Sendable { public var version: Int public var providers: [ProviderConfig] + /// Optional external event hooks. Absent (nil) or disabled means no hooks run. + public var hooks: HooksConfig? - public init(version: Int = Self.currentVersion, providers: [ProviderConfig]) { + public init( + version: Int = Self.currentVersion, + providers: [ProviderConfig], + hooks: HooksConfig? = nil) + { self.version = version self.providers = providers + self.hooks = hooks } public static func makeDefault( @@ -66,7 +73,8 @@ public struct CodexBarConfig: Codable, Sendable { return CodexBarConfig( version: Self.currentVersion, - providers: normalized) + providers: normalized, + hooks: self.hooks) } public func orderedProviders() -> [UsageProvider] { diff --git a/Sources/CodexBarCore/Hooks/HookEvent.swift b/Sources/CodexBarCore/Hooks/HookEvent.swift new file mode 100644 index 0000000000..3319dfa1e8 --- /dev/null +++ b/Sources/CodexBarCore/Hooks/HookEvent.swift @@ -0,0 +1,97 @@ +import Foundation + +/// The quota/provider state changes CodexBar can run external commands for. +/// +/// Raw values are the stable event names used in config, env vars, and the JSON +/// payload. `cost_threshold_reached` is intentionally not part of v1. +public enum HookEventType: String, Codable, Sendable, CaseIterable { + case quotaLow = "quota_low" + case quotaReached = "quota_reached" + case quotaReset = "quota_reset" + case providerUnavailable = "provider_unavailable" + case providerRecovered = "provider_recovered" + case refreshFailed = "refresh_failed" +} + +/// A single quota/provider event, with the metadata handed to an external hook +/// command via environment variables and a JSON stdin payload. +/// +/// `usagePercent` is a 0...1 fraction (0.92 == 92% used) to match the spec. It +/// carries only non-secret observability data; `account` is already redacted by +/// the caller when the user hides personal info. +public struct HookEvent: Codable, Sendable, Equatable { + public let event: HookEventType + public let provider: String + public let account: String? + public let window: String? + public let usagePercent: Double? + public let used: Double? + public let limit: Double? + public let resetAt: Date? + public let status: String? + public let timestamp: Date + + public init( + event: HookEventType, + provider: String, + account: String? = nil, + window: String? = nil, + usagePercent: Double? = nil, + used: Double? = nil, + limit: Double? = nil, + resetAt: Date? = nil, + status: String? = nil, + timestamp: Date) + { + self.event = event + self.provider = provider + self.account = account + self.window = window + self.usagePercent = usagePercent + self.used = used + self.limit = limit + self.resetAt = resetAt + self.status = status + self.timestamp = timestamp + } + + /// Environment variables passed to the hook command. Nil fields are omitted so + /// a script can distinguish "absent" from "zero". + public func environmentVariables() -> [String: String] { + var env: [String: String] = [ + "CODEXBAR_EVENT": self.event.rawValue, + "CODEXBAR_PROVIDER": self.provider, + "CODEXBAR_TIMESTAMP": Self.iso8601String(self.timestamp), + ] + if let account { env["CODEXBAR_ACCOUNT"] = account } + if let window { env["CODEXBAR_WINDOW"] = window } + if let usagePercent { env["CODEXBAR_USAGE_PERCENT"] = Self.number(usagePercent) } + if let used { env["CODEXBAR_USED"] = Self.number(used) } + if let limit { env["CODEXBAR_LIMIT"] = Self.number(limit) } + if let resetAt { env["CODEXBAR_RESET_AT"] = Self.iso8601String(resetAt) } + if let status { env["CODEXBAR_STATUS"] = status } + return env + } + + /// The JSON written to the hook command's stdin. + public func jsonPayload() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(self) + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + private static func number(_ value: Double) -> String { + // Trim a trailing ".0" so integers read cleanly, keep fractions intact. + if value == value.rounded(), abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + } +} diff --git a/Sources/CodexBarCore/Hooks/HookRateLimiter.swift b/Sources/CodexBarCore/Hooks/HookRateLimiter.swift new file mode 100644 index 0000000000..948be7f36f --- /dev/null +++ b/Sources/CodexBarCore/Hooks/HookRateLimiter.swift @@ -0,0 +1,37 @@ +import Foundation + +/// In-memory storm suppression: fire a given (event, provider, account, window) +/// at most once per `window` seconds. Quota events already dedupe upstream via +/// CodexBar's threshold/depletion/reset state; this is a backstop for +/// `provider_unavailable` / `refresh_failed`, which can otherwise repeat every +/// refresh while an outage persists. In-memory only: the state resets on relaunch. +public actor HookRateLimiter { + public static let defaultWindow: TimeInterval = 600 // 10 minutes + + private var lastFired: [String: Date] = [:] + private let window: TimeInterval + + public init(window: TimeInterval = HookRateLimiter.defaultWindow) { + self.window = window + } + + /// Records a fire for `event` at `now` and returns whether it is allowed + /// (i.e. no matching fire within the window). Call once per candidate dispatch. + public func allow(_ event: HookEvent, now: Date = Date()) -> Bool { + let key = Self.key(for: event) + if let previous = self.lastFired[key], now.timeIntervalSince(previous) < self.window { + return false + } + self.lastFired[key] = now + return true + } + + static func key(for event: HookEvent) -> String { + [ + event.event.rawValue, + event.provider, + event.account ?? "", + event.window ?? "", + ].joined(separator: "\u{1F}") + } +} diff --git a/Sources/CodexBarCore/Hooks/HookRule.swift b/Sources/CodexBarCore/Hooks/HookRule.swift new file mode 100644 index 0000000000..3555d9991b --- /dev/null +++ b/Sources/CodexBarCore/Hooks/HookRule.swift @@ -0,0 +1,91 @@ +import Foundation + +/// A user-configured hook: when `event` fires (optionally scoped to `provider` +/// and, for `quotaLow`, gated by `threshold`), run `executable` with `arguments`. +public struct HookRule: Codable, Sendable, Equatable, Identifiable { + /// Stable identity for SwiftUI list editing; defaults to a fresh UUID string. + public var id: String + public var enabled: Bool + public var event: HookEventType + /// Provider raw value (e.g. "codex"). Nil matches any provider. + public var provider: String? + /// For `quotaLow`: fire only when `usagePercent >= threshold` (0...1). Ignored otherwise. + public var threshold: Double? + public var executable: String + public var arguments: [String] + public var timeoutSeconds: Double + + public static let defaultTimeoutSeconds: Double = 10 + + public init( + id: String = UUID().uuidString, + enabled: Bool = true, + event: HookEventType, + provider: String? = nil, + threshold: Double? = nil, + executable: String, + arguments: [String] = [], + timeoutSeconds: Double = HookRule.defaultTimeoutSeconds) + { + self.id = id + self.enabled = enabled + self.event = event + self.provider = provider + self.threshold = threshold + self.executable = executable + self.arguments = arguments + self.timeoutSeconds = timeoutSeconds + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + self.enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + self.event = try container.decode(HookEventType.self, forKey: .event) + self.provider = try container.decodeIfPresent(String.self, forKey: .provider) + self.threshold = try container.decodeIfPresent(Double.self, forKey: .threshold) + self.executable = try container.decode(String.self, forKey: .executable) + self.arguments = try container.decodeIfPresent([String].self, forKey: .arguments) ?? [] + self.timeoutSeconds = try container.decodeIfPresent(Double.self, forKey: .timeoutSeconds) + ?? Self.defaultTimeoutSeconds + } + + /// True when this rule should run for the given event. + /// + /// Requires an absolute executable path: hook commands are never resolved via + /// PATH or a shell, so a relative path can never match. + public func matches(_ event: HookEvent) -> Bool { + guard self.enabled else { return false } + guard self.event == event.event else { return false } + guard (self.executable as NSString).isAbsolutePath else { return false } + if let provider = self.provider, provider != event.provider { return false } + if self.event == .quotaLow, let threshold = self.threshold { + guard let usage = event.usagePercent, usage >= threshold else { return false } + } + return true + } +} + +/// The top-level `hooks` section of the shared CodexBar config. Absent or +/// `enabled == false` means hooks never run. +public struct HooksConfig: Codable, Sendable, Equatable { + public var enabled: Bool + public var events: [HookRule] + + public init(enabled: Bool = false, events: [HookRule] = []) { + self.enabled = enabled + self.events = events + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + self.events = try container.decodeIfPresent([HookRule].self, forKey: .events) ?? [] + } + + /// Enabled rules that match the event. Returns nothing when hooks are disabled. + public func matchingRules(for event: HookEvent) -> [HookRule] { + guard self.enabled else { return [] } + return self.events.filter { $0.matches(event) } + } +} diff --git a/Sources/CodexBarCore/Hooks/HookRunner.swift b/Sources/CodexBarCore/Hooks/HookRunner.swift new file mode 100644 index 0000000000..91b0864010 --- /dev/null +++ b/Sources/CodexBarCore/Hooks/HookRunner.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Executes hook commands for quota/provider events. +/// +/// Reuses `SubprocessRunner` for the actual process work: it validates the +/// executable path, runs the binary directly (no shell), injects the environment, +/// enforces a timeout with SIGTERM→SIGKILL escalation, and logs only the binary +/// name (never env values or the account). Event metadata reaches the command via +/// environment variables and a JSON stdin payload. +public enum HookRunner { + private static let log = CodexBarLog.logger(LogCategories.hooks) + + /// Runs a single rule for an event to completion. Throws `SubprocessRunnerError` + /// on a missing/invalid executable, timeout, or non-zero exit. + @discardableResult + public static func run( + rule: HookRule, + event: HookEvent, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) async throws -> SubprocessResult + { + var environment = baseEnvironment + for (key, value) in event.environmentVariables() { + environment[key] = value + } + + // Small payload (< 1KB) fits the OS pipe buffer (~64KB), so we can write it + // and close the write end before launch; the child reads buffered bytes then EOF. + let stdin = Pipe() + let payload = try event.jsonPayload() + stdin.fileHandleForWriting.write(payload) + try? stdin.fileHandleForWriting.close() + + return try await SubprocessRunner.run( + binary: rule.executable, + arguments: rule.arguments, + environment: environment, + timeout: rule.timeoutSeconds, + standardInput: stdin, + acceptsNonZeroExit: false, + label: "hook \(event.event.rawValue)") + } + + /// Runs every enabled rule matching the event, subject to the rate limiter. + /// Fire-and-forget friendly: failures are logged, never thrown to the caller. + public static func dispatch( + event: HookEvent, + config: HooksConfig, + rateLimiter: HookRateLimiter, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) async + { + let rules = config.matchingRules(for: event) + guard !rules.isEmpty else { return } + guard await rateLimiter.allow(event) else { + self.log.debug("suppressed by rate limiter", metadata: ["event": "\(event.event.rawValue)"]) + return + } + for rule in rules { + do { + _ = try await self.run(rule: rule, event: event, baseEnvironment: baseEnvironment) + self.log.info( + "ran hook", + metadata: [ + "event": "\(event.event.rawValue)", + "provider": "\(event.provider)", + ]) + } catch { + self.log.warning( + "hook failed", + metadata: [ + "event": "\(event.event.rawValue)", + "error": "\(error.localizedDescription)", + ]) + } + } + } +} diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 547df9120e..c99ca22e34 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -33,6 +33,7 @@ public enum LogCategories { public static let elevenLabsUsage = "elevenlabs-usage" public static let geminiProbe = "gemini-probe" public static let grok = "grok" + public static let hooks = "hooks" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" public static let keychainPreflight = "keychain-preflight" diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 24aa785646..c4ab39eb12 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -425,6 +425,9 @@ struct LocalizationLanguageCatalogTests { "byte_unit_gigabyte", "byte_unit_kilobyte", "byte_unit_megabyte", + "hooks_executable_placeholder", + "hooks_provider", + "hooks_threshold_placeholder", "language_arabic", "language_galician", "language_italian", diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index e0a3d0c9d4..6d64e816cf 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -14,6 +14,7 @@ struct PreferencesPaneSmokeTests { _ = GeneralPane(settings: settings).body _ = DisplayPane(settings: settings, store: store).body _ = AdvancedPane(settings: settings, store: store).body + _ = HooksPane(settings: settings).body _ = ProvidersPane(settings: settings, store: store).body _ = DebugPane(settings: settings, store: store).body _ = AboutPane(updater: DisabledUpdaterController()).body diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift new file mode 100644 index 0000000000..35ab13bf78 --- /dev/null +++ b/TestsLinux/HooksTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct HooksTests { + private func event( + _ type: HookEventType = .quotaReached, + provider: String = "codex", + usagePercent: Double? = 0.95, + account: String? = nil, + window: String? = "session") -> HookEvent + { + HookEvent( + event: type, + provider: provider, + account: account, + window: window, + usagePercent: usagePercent, + resetAt: Date(timeIntervalSince1970: 1_700_000_000), + timestamp: Date(timeIntervalSince1970: 1_700_000_100)) + } + + // MARK: - Matching + + @Test + func `rule matches on event and provider`() { + let rule = HookRule(event: .quotaReached, provider: "codex", executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaReached, provider: "codex"))) + #expect(!rule.matches(self.event(.quotaReached, provider: "claude"))) + #expect(!rule.matches(self.event(.quotaLow, provider: "codex"))) + } + + @Test + func `nil provider matches any provider`() { + let rule = HookRule(event: .quotaReached, provider: nil, executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaReached, provider: "codex"))) + #expect(rule.matches(self.event(.quotaReached, provider: "claude"))) + } + + @Test + func `quotaLow threshold gates on usage percent`() { + let rule = HookRule(event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaLow, usagePercent: 0.92))) + #expect(rule.matches(self.event(.quotaLow, usagePercent: 0.90))) + #expect(!rule.matches(self.event(.quotaLow, usagePercent: 0.80))) + #expect(!rule.matches(self.event(.quotaLow, usagePercent: nil))) + } + + @Test + func `disabled rule and relative path never match`() { + let disabled = HookRule(enabled: false, event: .quotaReached, executable: "/bin/echo") + #expect(!disabled.matches(self.event())) + + let relative = HookRule(event: .quotaReached, executable: "my-command") + #expect(!relative.matches(self.event())) + } + + @Test + func `disabled config yields no matching rules`() { + let rule = HookRule(event: .quotaReached, executable: "/bin/echo") + let enabled = HooksConfig(enabled: true, events: [rule]) + let disabled = HooksConfig(enabled: false, events: [rule]) + #expect(enabled.matchingRules(for: self.event()).count == 1) + #expect(disabled.matchingRules(for: self.event()).isEmpty) + } + + // MARK: - Payload + + @Test + func `environment variables include set fields and omit nil`() { + let env = self.event(.quotaLow, usagePercent: 0.5, account: nil, window: "weekly") + .environmentVariables() + #expect(env["CODEXBAR_EVENT"] == "quota_low") + #expect(env["CODEXBAR_PROVIDER"] == "codex") + #expect(env["CODEXBAR_WINDOW"] == "weekly") + #expect(env["CODEXBAR_USAGE_PERCENT"] == "0.5") + #expect(env["CODEXBAR_RESET_AT"] == "2023-11-14T22:13:20Z") + #expect(env["CODEXBAR_TIMESTAMP"] != nil) + #expect(env["CODEXBAR_ACCOUNT"] == nil) + #expect(env["CODEXBAR_STATUS"] == nil) + } + + @Test + func `json payload round-trips`() throws { + let original = self.event(.quotaReached, provider: "claude", usagePercent: 0.42, window: "session") + let data = try original.jsonPayload() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(HookEvent.self, from: data) + #expect(decoded.event == .quotaReached) + #expect(decoded.provider == "claude") + #expect(decoded.usagePercent == 0.42) + #expect(decoded.window == "session") + } + + // MARK: - Rate limiter + + @Test + func `rate limiter suppresses same key within window`() async { + let limiter = HookRateLimiter(window: 600) + let base = Date(timeIntervalSince1970: 1_000_000) + #expect(await limiter.allow(self.event(), now: base)) + #expect(await !limiter.allow(self.event(), now: base.addingTimeInterval(300))) + #expect(await limiter.allow(self.event(), now: base.addingTimeInterval(601))) + } + + @Test + func `rate limiter treats distinct keys independently`() async { + let limiter = HookRateLimiter(window: 600) + let now = Date(timeIntervalSince1970: 1_000_000) + #expect(await limiter.allow(self.event(provider: "codex"), now: now)) + #expect(await limiter.allow(self.event(provider: "claude"), now: now)) + } + + // MARK: - Runner + + @Test + func `runner executes command and passes environment`() async throws { + // /usr/bin/env prints the environment; assert our injected vars reach the child. + let rule = HookRule(event: .quotaReached, executable: "/usr/bin/env") + let result = try await HookRunner.run(rule: rule, event: self.event()) + #expect(result.stdout.contains("CODEXBAR_EVENT=quota_reached")) + #expect(result.stdout.contains("CODEXBAR_PROVIDER=codex")) + } + + @Test + func `runner throws on missing executable`() async { + let rule = HookRule(event: .quotaReached, executable: "/nonexistent/codexbar-hook") + await #expect(throws: SubprocessRunnerError.self) { + try await HookRunner.run(rule: rule, event: self.event()) + } + } +} From d7130ae0fac3bb692c5259985133e0550d60462b Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Wed, 8 Jul 2026 21:41:50 -0700 Subject: [PATCH 2/5] Harden hooks per review: minimal env, scoped rate limiting, redacted logs Address automated review feedback on #2001: - Forward only a narrow allowlist of environment variables to hook commands (PATH/HOME/USER/SHELL/locale/TMPDIR) plus the event's CODEXBAR_* values, so a provider API key living in CodexBar's environment can never leak to a hook. - Rate-limit only the storm-prone events (provider_unavailable, refresh_failed). Quota events dedupe upstream; throttling them dropped a lower remaining-quota warning that crossed within the 10-minute window (Codex P2). - Redact hook failure logs: log the event and a coarse reason (exit code, timeout, not-found) instead of the subprocess stderr, which could echo the payload. Adds tests for the rate-limit classification and the environment allowlist. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBarCore/Hooks/HookEvent.swift | 13 +++++++++ Sources/CodexBarCore/Hooks/HookRunner.swift | 32 +++++++++++++++++++-- TestsLinux/HooksTests.swift | 22 +++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Hooks/HookEvent.swift b/Sources/CodexBarCore/Hooks/HookEvent.swift index 3319dfa1e8..a604f3296a 100644 --- a/Sources/CodexBarCore/Hooks/HookEvent.swift +++ b/Sources/CodexBarCore/Hooks/HookEvent.swift @@ -11,6 +11,19 @@ public enum HookEventType: String, Codable, Sendable, CaseIterable { case providerUnavailable = "provider_unavailable" case providerRecovered = "provider_recovered" case refreshFailed = "refresh_failed" + + /// Events that can repeat on every refresh while a condition persists, so they + /// get the rate-limiter backstop. Quota events dedupe upstream and must not be + /// throttled here, or a lower remaining-quota warning crossing within the + /// window would be dropped. + var isRateLimited: Bool { + switch self { + case .providerUnavailable, .refreshFailed: + true + case .quotaLow, .quotaReached, .quotaReset, .providerRecovered: + false + } + } } /// A single quota/provider event, with the metadata handed to an external hook diff --git a/Sources/CodexBarCore/Hooks/HookRunner.swift b/Sources/CodexBarCore/Hooks/HookRunner.swift index 91b0864010..ff94ab45e8 100644 --- a/Sources/CodexBarCore/Hooks/HookRunner.swift +++ b/Sources/CodexBarCore/Hooks/HookRunner.swift @@ -10,6 +10,15 @@ import Foundation public enum HookRunner { private static let log = CodexBarLog.logger(LogCategories.hooks) + /// Environment keys forwarded to a hook. Deliberately narrow: CodexBar's own + /// process environment may hold provider API keys/tokens, and hooks must never + /// receive secrets. Only these general-purpose vars pass through, plus the + /// event's own `CODEXBAR_*` values. + private static let forwardedEnvironmentKeys: Set = [ + "PATH", "HOME", "USER", "LOGNAME", "SHELL", + "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR", + ] + /// Runs a single rule for an event to completion. Throws `SubprocessRunnerError` /// on a missing/invalid executable, timeout, or non-zero exit. @discardableResult @@ -18,7 +27,7 @@ public enum HookRunner { event: HookEvent, baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) async throws -> SubprocessResult { - var environment = baseEnvironment + var environment = baseEnvironment.filter { Self.forwardedEnvironmentKeys.contains($0.key) } for (key, value) in event.environmentVariables() { environment[key] = value } @@ -50,7 +59,11 @@ public enum HookRunner { { let rules = config.matchingRules(for: event) guard !rules.isEmpty else { return } - guard await rateLimiter.allow(event) else { + // Quota events already dedupe upstream (threshold-crossing, depletion, and + // reset-edge state), and rate-limiting them here would suppress a lower + // remaining-quota warning that crosses within the window. Only the events + // that can repeat every refresh while a condition persists are throttled. + if event.event.isRateLimited, await !rateLimiter.allow(event) { self.log.debug("suppressed by rate limiter", metadata: ["event": "\(event.event.rawValue)"]) return } @@ -64,13 +77,26 @@ public enum HookRunner { "provider": "\(event.provider)", ]) } catch { + // Redacted: never log hook stderr (it can echo the payload/env). Log + // only the event and a coarse failure reason. self.log.warning( "hook failed", metadata: [ "event": "\(event.event.rawValue)", - "error": "\(error.localizedDescription)", + "provider": "\(event.provider)", + "reason": "\(Self.redactedFailureReason(error))", ]) } } } + + private static func redactedFailureReason(_ error: Error) -> String { + guard let error = error as? SubprocessRunnerError else { return "error" } + switch error { + case .binaryNotFound: return "executable not found" + case .launchFailed: return "launch failed" + case .timedOut: return "timed out" + case let .nonZeroExit(code, _): return "exit \(code)" + } + } } diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift index 35ab13bf78..cde1b0d8a3 100644 --- a/TestsLinux/HooksTests.swift +++ b/TestsLinux/HooksTests.swift @@ -115,7 +115,17 @@ struct HooksTests { // MARK: - Runner @Test - func `runner executes command and passes environment`() async throws { + func `only storm-prone events are rate limited`() { + #expect(HookEventType.refreshFailed.isRateLimited) + #expect(HookEventType.providerUnavailable.isRateLimited) + #expect(!HookEventType.quotaLow.isRateLimited) + #expect(!HookEventType.quotaReached.isRateLimited) + #expect(!HookEventType.quotaReset.isRateLimited) + #expect(!HookEventType.providerRecovered.isRateLimited) + } + + @Test + func `runner executes command and passes event environment`() async throws { // /usr/bin/env prints the environment; assert our injected vars reach the child. let rule = HookRule(event: .quotaReached, executable: "/usr/bin/env") let result = try await HookRunner.run(rule: rule, event: self.event()) @@ -123,6 +133,16 @@ struct HooksTests { #expect(result.stdout.contains("CODEXBAR_PROVIDER=codex")) } + @Test + func `runner does not forward secrets from the base environment`() async throws { + let rule = HookRule(event: .quotaReached, executable: "/usr/bin/env") + let base = ["PATH": "/usr/bin:/bin", "ANTHROPIC_API_KEY": "sk-should-not-leak"] + let result = try await HookRunner.run(rule: rule, event: self.event(), baseEnvironment: base) + #expect(result.stdout.contains("PATH=/usr/bin:/bin")) // safe var forwarded + #expect(!result.stdout.contains("sk-should-not-leak")) // secret dropped + #expect(!result.stdout.contains("ANTHROPIC_API_KEY")) + } + @Test func `runner throws on missing executable`() async { let rule = HookRule(event: .quotaReached, executable: "/nonexistent/codexbar-hook") From fa11d2d8db83da5dfb6d86e74947f732d434eec8 Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Wed, 8 Jul 2026 22:06:51 -0700 Subject: [PATCH 3/5] Decouple quota hooks from notifications; skip refresh on hook edits Address the remaining P2 review points on #2001: - Quota hooks no longer depend on notification preferences. Quota-warning and session-depletion detection now runs when either notifications OR a matching hook rule is enabled, and only the notification post is gated on the notification settings. Gating is keyed on hasQuotaHookRule(event:provider:), so behavior is byte-identical for users without a configured quota hook. - Editing hook rules no longer triggers a provider refresh. Hook config writes go through a dedicated path that persists and bumps a separate hooksRevision for the settings pane, without bumping configRevision (which drives the background refresh introduced by #1996). Bundles the per-refresh quota-warning constants into QuotaWarningTransitionContext to keep the transition helper within the parameter-count limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/SettingsStore+Config.swift | 19 ++++-- Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/UsageStore+Hooks.swift | 15 +++++ .../CodexBar/UsageStore+QuotaWarnings.swift | 67 +++++++++++++------ Sources/CodexBar/UsageStore.swift | 14 +++- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index 24f8e01a2d..2f5054f267 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -87,7 +87,11 @@ extension SettingsStore { // MARK: - Hooks var hooksConfig: HooksConfig { - self.configSnapshot.hooks ?? HooksConfig() + // Observe both revisions: local hook edits bump hooksRevision (no provider + // refresh), external config syncs bump configRevision. + _ = self.hooksRevision + _ = self.configRevision + return self.config.hooks ?? HooksConfig() } var hooksEnabled: Bool { @@ -121,11 +125,14 @@ extension SettingsStore { } private func updateHooks(_ mutate: (inout HooksConfig) -> Void) { - self.updateConfig(reason: "hooks") { config in - var hooks = config.hooks ?? HooksConfig() - mutate(&hooks) - config.hooks = (hooks.enabled || !hooks.events.isEmpty) ? hooks : nil - } + guard !self.configLoading else { return } + // Hooks never affect provider fetching, so persist and notify the UI without + // bumping configRevision (which would trigger a provider refresh). + var hooks = self.config.hooks ?? HooksConfig() + mutate(&hooks) + self.config.hooks = (hooks.enabled || !hooks.events.isEmpty) ? hooks : nil + self.schedulePersistConfig() + self.hooksRevision &+= 1 } var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f14282ba23..5a033aab46 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -210,6 +210,9 @@ final class SettingsStore { var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 + /// Bumped on hook-rule edits so the Hooks pane re-renders. Kept separate from + /// `configRevision` so editing a hook does not trigger a provider refresh. + var hooksRevision: Int = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index 08600f64fa..e16cc1c023 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -78,6 +78,21 @@ extension UsageStore { } } + /// True when the user has an enabled hook rule for this event and provider. + /// + /// Used to run quota transition detection even when the matching notification + /// preference is off, so hooks fire independently of notifications. Returns + /// false for everyone who has not configured such a rule, so notification + /// behavior is unchanged for them. + func hasQuotaHookRule(event: HookEventType, provider: UsageProvider) -> Bool { + guard let hooks = self.settings.config.hooks, hooks.enabled else { return false } + return hooks.events.contains { rule in + rule.enabled + && rule.event == event + && (rule.provider == nil || rule.provider == provider.rawValue) + } + } + /// Account label for a hook payload, redacted when the user hides personal info. func hookAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { guard !self.settings.hidePersonalInfo else { return nil } diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index b3a58a057f..daf3129f1b 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -31,12 +31,26 @@ extension UsageStore { var firedThresholds: Set = [] var source: SessionQuotaWindowSource? } + + /// Per-refresh constants shared across the quota-warning lanes: which window + /// source is active, the redacted account label, and whether notifications and + /// hooks are each enabled for this provider. + struct QuotaWarningTransitionContext { + let source: SessionQuotaWindowSource? + let accountDisplayName: String? + let notificationsEnabled: Bool + let hooksActive: Bool + } } @MainActor extension UsageStore { func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { - guard self.settings.quotaWarningNotificationsEnabled else { return } + let notificationsEnabled = self.settings.quotaWarningNotificationsEnabled + // Hooks have their own enable switch, so a configured quota_low hook must + // fire even when the user turned quota warning notifications off. + let hooksActive = self.hasQuotaHookRule(event: .quotaLow, provider: provider) + guard notificationsEnabled || hooksActive else { return } if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } let accountDisplayName = self.quotaWarningAccountDisplayName(provider: provider, snapshot: snapshot) @@ -56,22 +70,25 @@ extension UsageStore { primaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.primary secondaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.secondary } + let context = QuotaWarningTransitionContext( + source: source, + accountDisplayName: accountDisplayName, + notificationsEnabled: notificationsEnabled, + hooksActive: hooksActive) self.handleQuotaWarningTransition( provider: provider, window: .session, rateWindow: primaryWindow, - source: source, - accountDisplayName: accountDisplayName) + context: context) self.handleQuotaWarningTransition( provider: provider, window: .weekly, rateWindow: secondaryWindow, - source: source, - accountDisplayName: accountDisplayName) + context: context) self.handleClaudeExtraWindowQuotaWarnings( provider: provider, snapshot: snapshot, - accountDisplayName: accountDisplayName) + context: context) } /// Emit weekly-lane quota warnings for Claude's extra rate windows — model-scoped weekly @@ -81,10 +98,12 @@ extension UsageStore { private func handleClaudeExtraWindowQuotaWarnings( provider: UsageProvider, snapshot: UsageSnapshot, - accountDisplayName: String?) + context: QuotaWarningTransitionContext) { guard provider == .claude else { return } - guard self.settings.quotaWarningEnabled(provider: provider, window: .weekly) else { + let weeklyNotify = context.notificationsEnabled + && self.settings.quotaWarningEnabled(provider: provider, window: .weekly) + guard weeklyNotify || context.hooksActive else { let extraWindowKeys = self.quotaWarningState.keys.filter { $0.provider == provider && $0.windowID != nil } @@ -100,8 +119,7 @@ extension UsageStore { provider: provider, window: .weekly, rateWindow: named.window, - source: nil, - accountDisplayName: accountDisplayName, + context: context, windowID: named.id, windowDisplayLabel: named.title) } @@ -127,13 +145,16 @@ extension UsageStore { provider: UsageProvider, window: QuotaWarningWindow, rateWindow: RateWindow?, - source: SessionQuotaWindowSource?, - accountDisplayName: String?, + context: QuotaWarningTransitionContext, windowID: String? = nil, windowDisplayLabel: String? = nil) { + let source = context.source + let accountDisplayName = context.accountDisplayName let key = QuotaWarningStateKey(provider: provider, window: window, windowID: windowID) - guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { + let notify = context.notificationsEnabled + && self.settings.quotaWarningEnabled(provider: provider, window: window) + guard notify || context.hooksActive else { self.quotaWarningState.removeValue(forKey: key) return } @@ -166,15 +187,17 @@ extension UsageStore { state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( threshold: threshold, thresholds: thresholds)) - self.postQuotaWarning( - QuotaWarningEvent( - window: window, - threshold: threshold, - currentRemaining: currentRemaining, - accountDisplayName: accountDisplayName, - windowID: windowID, - windowDisplayLabel: windowDisplayLabel), - provider: provider) + if notify { + self.postQuotaWarning( + QuotaWarningEvent( + window: window, + threshold: threshold, + currentRemaining: currentRemaining, + accountDisplayName: accountDisplayName, + windowID: windowID, + windowDisplayLabel: windowDisplayLabel), + provider: provider) + } self.emitHook( .quotaLow, provider: provider, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 7cf8ddea5e..840cb2b5e5 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -898,7 +898,11 @@ final class UsageStore { self.lastKnownSessionWindowSource[provider] = currentSource } - guard self.settings.sessionQuotaNotificationsEnabled else { + // Hooks have their own enable switch, so a configured quota_reached hook must + // fire on a real depletion even when session quota notifications are off. + let notificationsEnabled = self.settings.sessionQuotaNotificationsEnabled + let hooksActive = self.hasQuotaHookRule(event: .quotaReached, provider: provider) + guard notificationsEnabled || hooksActive else { if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || SessionQuotaNotificationLogic.isDepleted(previousRemaining) { @@ -916,7 +920,9 @@ final class UsageStore { let providerText = provider.rawValue let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) - self.sessionQuotaNotifier.post(transition: .depleted, provider: provider, badge: nil) + if notificationsEnabled { + self.sessionQuotaNotifier.post(transition: .depleted, provider: provider, badge: nil) + } self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) } return @@ -945,7 +951,9 @@ final class UsageStore { "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) - self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) + if notificationsEnabled { + self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) + } if transition == .depleted { self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) } From 247bcb7a0244847545270e0725a39e7ee72e1868 Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Thu, 9 Jul 2026 07:54:48 -0700 Subject: [PATCH 4/5] Drive quota_low hooks by each rule's own usage threshold Address ClawSweeper's remaining P2 blocker (UsageStore+QuotaWarnings.swift): quota_low hooks were emitted only on notification-threshold crossings, so a rule at threshold 0.90 could never fire under the default 50/20 remaining notification thresholds. quota_low hooks now run on a dedicated path, fully separate from the notification machinery: - Per-lane usage is tracked and each matching rule fires when usage crosses its own threshold upward. A rule without a threshold falls back to the provider's notification thresholds so a plain low-quota hook still fires at the app's warning points. - The notification path reverts to its original notification-only form; hooks no longer piggyback on it. - The crossing selection is extracted to QuotaLowHookThreshold.crossedRules in Core and unit-tested (own-threshold crossing, thresholdless fallback, and selecting only the newly-crossed rule among several). Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/UsageStore+Hooks.swift | 67 ++++++++++ .../CodexBar/UsageStore+QuotaWarnings.swift | 126 +++++++++--------- Sources/CodexBar/UsageStore.swift | 3 + Sources/CodexBarCore/Hooks/HookRule.swift | 22 +++ TestsLinux/HooksTests.swift | 40 ++++++ 5 files changed, 198 insertions(+), 60 deletions(-) diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index e16cc1c023..f68e072e75 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -78,6 +78,73 @@ extension UsageStore { } } + /// Fires `quota_low` hooks driven by each rule's own usage threshold, crossed + /// upward, independent of the notification thresholds and preferences. A rule + /// with no threshold falls back to the provider's notification thresholds so a + /// "notify me when quota is low" hook still fires at the app's warning points. + /// Identifies a quota lane for quota_low hook crossing detection. + struct QuotaLowHookLane { + let window: QuotaWarningWindow + let windowID: String? + let label: String + } + + func dispatchQuotaLowHooks( + provider: UsageProvider, + lane: QuotaLowHookLane, + rateWindow: RateWindow?, + accountDisplayName: String?) + { + guard let hooks = self.settings.config.hooks, hooks.enabled else { return } + let rules = hooks.events.filter { rule in + rule.enabled + && rule.event == .quotaLow + && (rule.provider == nil || rule.provider == provider.rawValue) + } + guard !rules.isEmpty else { return } + + let key = QuotaWarningStateKey(provider: provider, window: lane.window, windowID: lane.windowID) + guard let rateWindow else { + self.quotaLowHookUsage.removeValue(forKey: key) + return + } + let current = rateWindow.usedPercent / 100 + let previous = self.quotaLowHookUsage[key] + self.quotaLowHookUsage[key] = current + // No crossing can be established from the first sample; avoid firing on a + // fresh launch when usage is already high. + guard let previous else { return } + + let fallbackThresholds = self.settings + .resolvedQuotaWarningThresholds(provider: provider, window: lane.window) + .map { (100.0 - Double($0)) / 100.0 } + let crossed = QuotaLowHookThreshold.crossedRules( + rules, + previousUsage: previous, + currentUsage: current, + fallbackThresholds: fallbackThresholds) + guard !crossed.isEmpty else { return } + + let event = HookEvent( + event: .quotaLow, + provider: provider.rawValue, + account: accountDisplayName, + window: lane.label, + usagePercent: current, + resetAt: rateWindow.resetsAt, + timestamp: Date()) + let config = HooksConfig(enabled: true, events: crossed) + let limiter = self.hookRateLimiter + let environment = self.environmentBase + Task.detached(priority: .utility) { + await HookRunner.dispatch( + event: event, + config: config, + rateLimiter: limiter, + baseEnvironment: environment) + } + } + /// True when the user has an enabled hook rule for this event and provider. /// /// Used to run quota transition detection even when the matching notification diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index daf3129f1b..128c067eca 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -31,24 +31,15 @@ extension UsageStore { var firedThresholds: Set = [] var source: SessionQuotaWindowSource? } - - /// Per-refresh constants shared across the quota-warning lanes: which window - /// source is active, the redacted account label, and whether notifications and - /// hooks are each enabled for this provider. - struct QuotaWarningTransitionContext { - let source: SessionQuotaWindowSource? - let accountDisplayName: String? - let notificationsEnabled: Bool - let hooksActive: Bool - } } @MainActor extension UsageStore { func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { let notificationsEnabled = self.settings.quotaWarningNotificationsEnabled - // Hooks have their own enable switch, so a configured quota_low hook must - // fire even when the user turned quota warning notifications off. + // Hooks have their own enable switch and their own per-rule thresholds, so + // quota_low hooks run on a separate path that does not depend on the + // notification preference or the notification thresholds. let hooksActive = self.hasQuotaHookRule(event: .quotaLow, provider: provider) guard notificationsEnabled || hooksActive else { return } if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } @@ -70,25 +61,53 @@ extension UsageStore { primaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.primary secondaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.secondary } - let context = QuotaWarningTransitionContext( - source: source, - accountDisplayName: accountDisplayName, - notificationsEnabled: notificationsEnabled, - hooksActive: hooksActive) - self.handleQuotaWarningTransition( - provider: provider, - window: .session, - rateWindow: primaryWindow, - context: context) - self.handleQuotaWarningTransition( - provider: provider, - window: .weekly, - rateWindow: secondaryWindow, - context: context) - self.handleClaudeExtraWindowQuotaWarnings( - provider: provider, - snapshot: snapshot, - context: context) + + if notificationsEnabled { + self.handleQuotaWarningTransition( + provider: provider, + window: .session, + rateWindow: primaryWindow, + source: source, + accountDisplayName: accountDisplayName) + self.handleQuotaWarningTransition( + provider: provider, + window: .weekly, + rateWindow: secondaryWindow, + source: source, + accountDisplayName: accountDisplayName) + self.handleClaudeExtraWindowQuotaWarnings( + provider: provider, + snapshot: snapshot, + accountDisplayName: accountDisplayName) + } + + if hooksActive { + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane( + window: .session, + windowID: nil, + label: QuotaWarningWindow.session.displayName), + rateWindow: primaryWindow, + accountDisplayName: accountDisplayName) + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane( + window: .weekly, + windowID: nil, + label: QuotaWarningWindow.weekly.displayName), + rateWindow: secondaryWindow, + accountDisplayName: accountDisplayName) + if provider == .claude { + for named in (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) { + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane(window: .weekly, windowID: named.id, label: named.title), + rateWindow: named.window, + accountDisplayName: accountDisplayName) + } + } + } } /// Emit weekly-lane quota warnings for Claude's extra rate windows — model-scoped weekly @@ -98,12 +117,10 @@ extension UsageStore { private func handleClaudeExtraWindowQuotaWarnings( provider: UsageProvider, snapshot: UsageSnapshot, - context: QuotaWarningTransitionContext) + accountDisplayName: String?) { guard provider == .claude else { return } - let weeklyNotify = context.notificationsEnabled - && self.settings.quotaWarningEnabled(provider: provider, window: .weekly) - guard weeklyNotify || context.hooksActive else { + guard self.settings.quotaWarningEnabled(provider: provider, window: .weekly) else { let extraWindowKeys = self.quotaWarningState.keys.filter { $0.provider == provider && $0.windowID != nil } @@ -119,7 +136,8 @@ extension UsageStore { provider: provider, window: .weekly, rateWindow: named.window, - context: context, + source: nil, + accountDisplayName: accountDisplayName, windowID: named.id, windowDisplayLabel: named.title) } @@ -145,16 +163,13 @@ extension UsageStore { provider: UsageProvider, window: QuotaWarningWindow, rateWindow: RateWindow?, - context: QuotaWarningTransitionContext, + source: SessionQuotaWindowSource?, + accountDisplayName: String?, windowID: String? = nil, windowDisplayLabel: String? = nil) { - let source = context.source - let accountDisplayName = context.accountDisplayName let key = QuotaWarningStateKey(provider: provider, window: window, windowID: windowID) - let notify = context.notificationsEnabled - && self.settings.quotaWarningEnabled(provider: provider, window: window) - guard notify || context.hooksActive else { + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { self.quotaWarningState.removeValue(forKey: key) return } @@ -187,24 +202,15 @@ extension UsageStore { state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( threshold: threshold, thresholds: thresholds)) - if notify { - self.postQuotaWarning( - QuotaWarningEvent( - window: window, - threshold: threshold, - currentRemaining: currentRemaining, - accountDisplayName: accountDisplayName, - windowID: windowID, - windowDisplayLabel: windowDisplayLabel), - provider: provider) - } - self.emitHook( - .quotaLow, - provider: provider, - window: windowDisplayLabel ?? window.displayName, - usagePercent: rateWindow.usedPercent / 100, - resetAt: rateWindow.resetsAt, - accountDisplayName: accountDisplayName) + self.postQuotaWarning( + QuotaWarningEvent( + window: window, + threshold: threshold, + currentRemaining: currentRemaining, + accountDisplayName: accountDisplayName, + windowID: windowID, + windowDisplayLabel: windowDisplayLabel), + provider: provider) } state.lastRemaining = currentRemaining diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 840cb2b5e5..f866dc0f5c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -265,6 +265,9 @@ final class UsageStore { @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] @ObservationIgnored let hookRateLimiter = HookRateLimiter() @ObservationIgnored var providerStatusHadIssue: [UsageProvider: Bool] = [:] + /// Last observed usage fraction (0...1) per quota-warning lane, used to detect + /// upward crossings of a quota_low hook rule's own threshold. + @ObservationIgnored var quotaLowHookUsage: [QuotaWarningStateKey: Double] = [:] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] diff --git a/Sources/CodexBarCore/Hooks/HookRule.swift b/Sources/CodexBarCore/Hooks/HookRule.swift index 3555d9991b..26068cacb8 100644 --- a/Sources/CodexBarCore/Hooks/HookRule.swift +++ b/Sources/CodexBarCore/Hooks/HookRule.swift @@ -66,6 +66,28 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { } } +public enum QuotaLowHookThreshold { + /// Returns the quota_low rules whose watched threshold was crossed upward + /// between `previousUsage` and `currentUsage` (both 0...1 usage fractions). + /// + /// A rule with an explicit `threshold` watches only that value, so its own + /// usage threshold drives emission independently of the notification + /// thresholds. A rule without a threshold falls back to `fallbackThresholds` + /// (the provider's notification thresholds, as usage fractions) so a plain + /// "notify me when quota is low" hook still fires at the app's warning points. + public static func crossedRules( + _ rules: [HookRule], + previousUsage: Double, + currentUsage: Double, + fallbackThresholds: [Double]) -> [HookRule] + { + rules.filter { rule in + let watched = rule.threshold.map { [$0] } ?? fallbackThresholds + return watched.contains { previousUsage < $0 && currentUsage >= $0 } + } + } +} + /// The top-level `hooks` section of the shared CodexBar config. Absent or /// `enabled == false` means hooks never run. public struct HooksConfig: Codable, Sendable, Equatable { diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift index cde1b0d8a3..3b3a62d4a9 100644 --- a/TestsLinux/HooksTests.swift +++ b/TestsLinux/HooksTests.swift @@ -64,6 +64,46 @@ struct HooksTests { #expect(disabled.matchingRules(for: self.event()).isEmpty) } + // MARK: - quota_low threshold crossing + + @Test + func `quotaLow rule fires only when its own threshold is crossed upward`() { + let rule = HookRule(event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + // Notification thresholds (50/20 remaining => 0.50/0.80 usage) would not fire + // a 0.90 rule; the rule's own threshold must drive it. + let fallback = [0.50, 0.80] + + // Crossing 0.90 upward fires it. + #expect(!QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.85, currentUsage: 0.95, fallbackThresholds: fallback).isEmpty) + // Already above, no new crossing. + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.92, currentUsage: 0.97, fallbackThresholds: fallback).isEmpty) + // Below threshold, no fire even though notification thresholds were crossed. + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.40, currentUsage: 0.85, fallbackThresholds: fallback).isEmpty) + } + + @Test + func `thresholdless quotaLow rule falls back to notification thresholds`() { + let rule = HookRule(event: .quotaLow, threshold: nil, executable: "/bin/echo") + let fallback = [0.50, 0.80] + #expect(!QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.40, currentUsage: 0.55, fallbackThresholds: fallback).isEmpty) + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.55, currentUsage: 0.60, fallbackThresholds: fallback).isEmpty) + } + + @Test + func `only the crossed rule is selected among several`() { + let low = HookRule(id: "low", event: .quotaLow, threshold: 0.50, executable: "/bin/echo") + let high = HookRule(id: "high", event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + // Usage rises past 0.90; 0.50 already fired earlier so must not re-fire. + let crossed = QuotaLowHookThreshold.crossedRules( + [low, high], previousUsage: 0.85, currentUsage: 0.95, fallbackThresholds: []) + #expect(crossed.map(\.id) == ["high"]) + } + // MARK: - Payload @Test From 24d4e96594d0862730c1e66a6660cc7ae22b7dfc Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Thu, 9 Jul 2026 08:26:23 -0700 Subject: [PATCH 5/5] Send hooks a coarse refresh_failed status instead of raw error text Address ClawSweeper's remaining P2 finding (UsageStore+Refresh.swift): the refresh_failed hook forwarded error.localizedDescription into CODEXBAR_STATUS and the JSON payload, and provider errors can embed response-body previews. The hook now receives a coarse, non-secret category (timeout, offline, network_error, auth_required, cancelled, error) via refreshFailureHookStatus, derived from the error type and NSURLError code only. Adds a test asserting URL errors map to categories and that a body-bearing provider error never leaks its description. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/UsageStore+Hooks.swift | 25 ++++++++++++++ Sources/CodexBar/UsageStore+Refresh.swift | 5 ++- .../RefreshFailureHookStatusTests.swift | 33 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/RefreshFailureHookStatusTests.swift diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index 3b90a2e0d9..ce22aefd2d 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -176,6 +176,31 @@ extension UsageStore { } } + /// Coarse, non-secret category for a refresh failure. Never forwards the raw + /// error description, which can include provider response-body previews. + nonisolated static func refreshFailureHookStatus(_ error: Error) -> String { + if error is CancellationError { return "cancelled" } + if isPermissionPromptWaiting(error) { return "auth_required" } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorCancelled: + return "cancelled" + case NSURLErrorTimedOut: + return "timeout" + case NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorCannotConnectToHost, + NSURLErrorCannotFindHost, + NSURLErrorDNSLookupFailed: + return "offline" + default: + return "network_error" + } + } + return "error" + } + /// Account label for a hook payload, redacted when the user hides personal info. func hookAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { guard !self.settings.hidePersonalInfo else { return nil } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 9f397028ec..4e3ccf385e 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -693,7 +693,10 @@ extension UsageStore { if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) } - self.emitHook(.refreshFailed, provider: provider, status: error.localizedDescription) + self.emitHook( + .refreshFailed, + provider: provider, + status: Self.refreshFailureHookStatus(error)) } else { self.errors[provider] = nil } diff --git a/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift b/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift new file mode 100644 index 0000000000..7f71155bc8 --- /dev/null +++ b/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CodexBar + +struct RefreshFailureHookStatusTests { + @Test + func `maps URL errors to coarse categories`() { + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + #expect(UsageStore.refreshFailureHookStatus(timeout) == "timeout") + + let offline = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) + #expect(UsageStore.refreshFailureHookStatus(offline) == "offline") + + let cancelled = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + #expect(UsageStore.refreshFailureHookStatus(cancelled) == "cancelled") + + #expect(UsageStore.refreshFailureHookStatus(CancellationError()) == "cancelled") + } + + @Test + func `never forwards the raw error description`() { + // A provider error whose description embeds a response-body preview must not + // leak into the hook status. + let leaky = NSError( + domain: "ProviderHTTP", + code: 500, + userInfo: [NSLocalizedDescriptionKey: "HTTP 500: {\"error\":\"secret-token abc123\"}"]) + let status = UsageStore.refreshFailureHookStatus(leaky) + #expect(status == "error") + #expect(!status.contains("secret-token")) + #expect(!status.contains("500")) + } +}