From 2882485ab8e9b27d2354c762e0c554b5cb054e0e Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Wed, 8 Jul 2026 21:09:45 -0700 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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")) + } +} From d4faec892ca94e2d87de74e17529639f185560bb Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Mon, 13 Jul 2026 07:55:09 -0700 Subject: [PATCH 06/13] Re-decouple quota_reached hook from notification preferences main's session-quota rewrite (SessionQuotaTransitionReducer) re-coupled quota_reached to notifications, which was an original requirement to avoid. Restore the decoupling on top of the reducer: - Run transition detection whenever session quota notifications OR a matching quota_reached hook rule are active (detectionEnabled = notificationsEnabled || hooksActive), gated by hasQuotaHookRule so behavior is byte-identical for users without such a hook. - Gate the OS notification post on the real notification setting; emit the quota_reached hook on any depletion regardless. - Relax the Codex notifications-disabled early return to also proceed when a hook rule is active. Extracted publishSessionQuotaTransition to keep cyclomatic complexity in range. Codex baseline restore tests and the session-quota suites remain green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UsageStore+SessionQuotaTransition.swift | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index bd968e3623..77d0d5bb5c 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -19,7 +19,14 @@ extension UsageStore { { return } - if provider == .codex, !self.settings.sessionQuotaNotificationsEnabled { + // 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. Run transition detection + // whenever notifications OR a matching hook rule is active; gate the OS notification post + // on the notification setting, but emit the hook on any depletion. + let notificationsEnabled = self.settings.sessionQuotaNotificationsEnabled + let hooksActive = self.hasQuotaHookRule(event: .quotaReached, provider: provider) + let detectionEnabled = notificationsEnabled || hooksActive + if provider == .codex, !detectionEnabled { self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) self.sessionQuotaLogger.debug("Codex session notifications disabled; cleared notification baseline") return @@ -73,7 +80,7 @@ extension UsageStore { observedAt: snapshot.updatedAt, evaluationTime: now, codexOwnerKey: codexOwnerKey), - notificationsEnabled: self.settings.sessionQuotaNotificationsEnabled, + notificationsEnabled: detectionEnabled, forceBaseline: forceBaseline) self.sessionQuotaTransitionStates[provider] = evaluation.state if provider == .codex { @@ -113,10 +120,28 @@ extension UsageStore { self.sessionQuotaLogger.info( "transition \(String(describing: transition)): provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") + self.publishSessionQuotaTransition( + transition, + provider: provider, + sessionWindow: sessionWindow, + snapshot: snapshot, + notificationsEnabled: notificationsEnabled) + } + } + + /// Posts the OS notification (only when enabled) and emits the quota_reached hook on depletion. + private func publishSessionQuotaTransition( + _ transition: SessionQuotaTransition, + provider: UsageProvider, + sessionWindow: (window: RateWindow, source: SessionQuotaWindowSource), + snapshot: UsageSnapshot, + notificationsEnabled: Bool) + { + if notificationsEnabled { self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) - if transition == .depleted { - self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) - } + } + if transition == .depleted { + self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) } } } From b14629bd91ef27b92b19ee4993fc14a05f9ffd1d Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Mon, 13 Jul 2026 09:00:07 -0700 Subject: [PATCH 07/13] Scope quota_low hook crossing history by account Address ClawSweeper's remaining P2 (UsageStore+Hooks.swift): quotaLowHookUsage was keyed by provider/window/windowID only, so when several accounts share one provider, one account's usage observations could suppress, clear, or re-arm another account's quota_low threshold crossing. Add a stable account discriminator (the unredacted account email, used only as an in-memory key, never logged or forwarded) to a dedicated QuotaLowHookUsageKey. Each account/lane now tracks its crossings independently. Adds a regression test covering two accounts on one provider plus lane independence. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/UsageStore+Hooks.swift | 27 +++++++++++--- .../CodexBar/UsageStore+QuotaWarnings.swift | 6 ++++ Sources/CodexBar/UsageStore.swift | 6 ++-- .../QuotaLowHookAccountScopingTests.swift | 35 +++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index ce22aefd2d..7e9e761580 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -94,10 +94,6 @@ 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 @@ -105,10 +101,27 @@ extension UsageStore { let label: String } + /// Key for per-account, per-lane quota_low crossing history. Includes the stable + /// account identifier so that, when several accounts share one provider, one + /// account's usage observations cannot suppress, clear, or re-arm another + /// account's threshold crossing. `account` is an in-memory discriminator only; + /// it is never logged or forwarded to a hook. + struct QuotaLowHookUsageKey: Hashable { + let provider: UsageProvider + let window: QuotaWarningWindow + let windowID: String? + let account: String? + } + + /// 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. func dispatchQuotaLowHooks( provider: UsageProvider, lane: QuotaLowHookLane, rateWindow: RateWindow?, + accountKey: String?, accountDisplayName: String?) { guard let hooks = self.settings.config.hooks, hooks.enabled else { return } @@ -119,7 +132,11 @@ extension UsageStore { } guard !rules.isEmpty else { return } - let key = QuotaWarningStateKey(provider: provider, window: lane.window, windowID: lane.windowID) + let key = QuotaLowHookUsageKey( + provider: provider, + window: lane.window, + windowID: lane.windowID, + account: accountKey) guard let rateWindow else { self.quotaLowHookUsage.removeValue(forKey: key) return diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index 1533b85cfc..e58660d0f2 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -50,6 +50,9 @@ extension UsageStore { } if hooksActive { + // Stable, unredacted account identifier used only as an in-memory key so + // separate accounts on one provider track their crossings independently. + let accountKey = snapshot.accountEmail(for: provider) self.dispatchQuotaLowHooks( provider: provider, lane: QuotaLowHookLane( @@ -57,6 +60,7 @@ extension UsageStore { windowID: nil, label: QuotaWarningWindow.session.displayName), rateWindow: primaryWindow, + accountKey: accountKey, accountDisplayName: accountDisplayName) self.dispatchQuotaLowHooks( provider: provider, @@ -65,6 +69,7 @@ extension UsageStore { windowID: nil, label: QuotaWarningWindow.weekly.displayName), rateWindow: secondaryWindow, + accountKey: accountKey, accountDisplayName: accountDisplayName) if provider == .claude { for named in (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) { @@ -72,6 +77,7 @@ extension UsageStore { provider: provider, lane: QuotaLowHookLane(window: .weekly, windowID: named.id, label: named.title), rateWindow: named.window, + accountKey: accountKey, accountDisplayName: accountDisplayName) } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3dbc25b63c..05ca166733 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -336,9 +336,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] = [:] + /// Last observed usage fraction (0...1) per account and quota-warning lane, used + /// to detect upward crossings of a quota_low hook rule's own threshold. + @ObservationIgnored var quotaLowHookUsage: [QuotaLowHookUsageKey: Double] = [:] @ObservationIgnored var predictivePaceWarningNotifiedKeys: Set = [] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] diff --git a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift new file mode 100644 index 0000000000..414d26c075 --- /dev/null +++ b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift @@ -0,0 +1,35 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct QuotaLowHookAccountScopingTests { + @Test + func `quota_low crossing history is scoped per account`() { + // Same provider/window/lane, different accounts must not share history: one + // account's high usage must not overwrite or re-arm another account's. + let accountA = UsageStore.QuotaLowHookUsageKey( + provider: .claude, window: .session, windowID: nil, account: "a@example.com") + let accountB = UsageStore.QuotaLowHookUsageKey( + provider: .claude, window: .session, windowID: nil, account: "b@example.com") + #expect(accountA != accountB) + + var usage: [UsageStore.QuotaLowHookUsageKey: Double] = [:] + usage[accountA] = 0.40 + usage[accountB] = 0.95 + // Account B's observation did not clobber account A's baseline. + #expect(usage[accountA] == 0.40) + #expect(usage[accountB] == 0.95) + } + + @Test + func `distinct windows and lanes stay independent for one account`() { + let session = UsageStore.QuotaLowHookUsageKey( + provider: .claude, window: .session, windowID: nil, account: "a@example.com") + let weekly = UsageStore.QuotaLowHookUsageKey( + provider: .claude, window: .weekly, windowID: nil, account: "a@example.com") + let scoped = UsageStore.QuotaLowHookUsageKey( + provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable", account: "a@example.com") + #expect(Set([session, weekly, scoped]).count == 3) + } +} From 60748f0b6e6c9919ca8d1d0250ff8c5ddd4f1b02 Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau Date: Tue, 14 Jul 2026 06:55:47 -0700 Subject: [PATCH 08/13] Use a composite identity, not just email, for quota_low hook account keys Refine the account discriminator for quota_low hook state (ClawSweeper P2): keying on the account email alone let email-less or same-email accounts share a baseline, so one account could suppress/clear/re-arm another's threshold crossing. quotaHookAccountKey now composes the full provider identity (email, organization, login method). Accounts without an email, or sharing one field, key independently whenever any other identity field differs; only truly identical identities (the same account) share a baseline. The pure composition is factored out and covered by tests for email-less, same-email, and identical-identity cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CodexBar/UsageStore+Hooks.swift | 28 +++++++++++++++++++ .../CodexBar/UsageStore+QuotaWarnings.swift | 4 +-- .../QuotaLowHookAccountScopingTests.swift | 20 +++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index 7e9e761580..3667c765ff 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -178,6 +178,34 @@ extension UsageStore { } } + /// Stable in-memory discriminator for per-account quota_low hook state. + /// + /// Composes the full provider identity (email, organization, login method) + /// rather than the email alone, so accounts without an email, or sharing one + /// identity field, still key independently when any other field differs. Only + /// truly identical identities (same account) share a baseline. Returns nil when + /// no identity field is known. Never logged or forwarded to a hook. + nonisolated static func quotaHookAccountKey( + provider: UsageProvider, + snapshot: UsageSnapshot) -> String? + { + guard let identity = snapshot.identity(for: provider) else { return nil } + return self.quotaHookAccountKey( + email: identity.accountEmail, + organization: identity.accountOrganization, + loginMethod: identity.loginMethod) + } + + nonisolated static func quotaHookAccountKey( + email: String?, + organization: String?, + loginMethod: String?) -> String? + { + let fields = [email, organization, loginMethod] + guard fields.contains(where: { $0?.isEmpty == false }) else { return nil } + return fields.map { $0 ?? "" }.joined(separator: "\u{1F}") + } + /// 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 e58660d0f2..8c8f72a879 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -50,9 +50,9 @@ extension UsageStore { } if hooksActive { - // Stable, unredacted account identifier used only as an in-memory key so + // Stable, unredacted account discriminator used only as an in-memory key so // separate accounts on one provider track their crossings independently. - let accountKey = snapshot.accountEmail(for: provider) + let accountKey = Self.quotaHookAccountKey(provider: provider, snapshot: snapshot) self.dispatchQuotaLowHooks( provider: provider, lane: QuotaLowHookLane( diff --git a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift index 414d26c075..042c021e4e 100644 --- a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift +++ b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift @@ -22,6 +22,26 @@ struct QuotaLowHookAccountScopingTests { #expect(usage[accountB] == 0.95) } + @Test + func `account discriminator separates email-less accounts by other identity fields`() { + // Two accounts with no email but different organizations must not collide. + let orgA = UsageStore.quotaHookAccountKey(email: nil, organization: "org-a", loginMethod: "oauth") + let orgB = UsageStore.quotaHookAccountKey(email: nil, organization: "org-b", loginMethod: "oauth") + #expect(orgA != nil) + #expect(orgA != orgB) + + // Same email but different login method still separates. + let sub = UsageStore.quotaHookAccountKey(email: "x@y.com", organization: nil, loginMethod: "subscription") + let api = UsageStore.quotaHookAccountKey(email: "x@y.com", organization: nil, loginMethod: "api") + #expect(sub != api) + + // Identical identity (same account) shares a key; no identity is nil. + let same1 = UsageStore.quotaHookAccountKey(email: "x@y.com", organization: "org", loginMethod: "oauth") + let same2 = UsageStore.quotaHookAccountKey(email: "x@y.com", organization: "org", loginMethod: "oauth") + #expect(same1 == same2) + #expect(UsageStore.quotaHookAccountKey(email: nil, organization: nil, loginMethod: nil) == nil) + } + @Test func `distinct windows and lanes stay independent for one account`() { let session = UsageStore.QuotaLowHookUsageKey( From 43cbcbc0127cec2760a922bf8d01c0f03c8994ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 01:32:34 +0100 Subject: [PATCH 09/13] fix: harden external event hooks --- Sources/CodexBar/CodexbarApp.swift | 15 +-- Sources/CodexBar/PreferencesHooksPane.swift | 58 +++++++-- .../Resources/ar.lproj/Localizable.strings | 5 +- .../Resources/ca.lproj/Localizable.strings | 5 +- .../Resources/de.lproj/Localizable.strings | 20 ++++ .../Resources/en.lproj/Localizable.strings | 5 +- .../Resources/es.lproj/Localizable.strings | 20 ++++ .../Resources/fa.lproj/Localizable.strings | 5 +- .../Resources/fr.lproj/Localizable.strings | 20 ++++ .../Resources/gl.lproj/Localizable.strings | 5 +- .../Resources/id.lproj/Localizable.strings | 5 +- .../Resources/it.lproj/Localizable.strings | 5 +- .../Resources/ja.lproj/Localizable.strings | 20 ++++ .../Resources/ko.lproj/Localizable.strings | 20 ++++ .../Resources/nl.lproj/Localizable.strings | 20 ++++ .../Resources/pl.lproj/Localizable.strings | 5 +- .../Resources/pt-BR.lproj/Localizable.strings | 20 ++++ .../Resources/ru.lproj/Localizable.strings | 20 ++++ .../Resources/sv.lproj/Localizable.strings | 20 ++++ .../Resources/th.lproj/Localizable.strings | 5 +- .../Resources/tr.lproj/Localizable.strings | 5 +- .../Resources/uk.lproj/Localizable.strings | 20 ++++ .../Resources/vi.lproj/Localizable.strings | 20 ++++ .../zh-Hans.lproj/Localizable.strings | 20 ++++ .../zh-Hant.lproj/Localizable.strings | 20 ++++ .../UsageStore+LimitResetCelebration.swift | 10 ++ .../Config/CodexBarConfigValidation.swift | 49 ++++++++ Sources/CodexBarCore/Hooks/HookRule.swift | 30 ++++- .../CodexBarTests/ConfigValidationTests.swift | 22 ++++ TestsLinux/HookDispatchTests.swift | 112 ++++++++++++++++++ TestsLinux/HooksTests.swift | 4 +- docs/cli.md | 5 + docs/configuration.md | 64 ++++++++++ 33 files changed, 645 insertions(+), 34 deletions(-) create mode 100644 TestsLinux/HookDispatchTests.swift diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 857535e611..e7b71bfb82 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -428,12 +428,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func handleSessionLimitResetNotification(_ notification: Notification) { guard let event = notification.object as? SessionLimitResetEvent else { return } - // Emit the hook regardless of the confetti preference; hooks have their own switch. - self.store?.emitQuotaResetHook( - provider: event.provider, - window: .session, - usedPercent: event.usedPercent, - accountLabel: event.accountLabel) guard self.settings?.confettiOnSessionLimitResetsEnabled == true else { return } self.playLimitResetConfetti( provider: event.provider, @@ -443,11 +437,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) { guard let event = notification.object as? WeeklyLimitResetEvent else { return } - self.store?.emitQuotaResetHook( - provider: event.provider, - window: .weekly, - usedPercent: event.usedPercent, - accountLabel: event.accountLabel) guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return } self.playLimitResetConfetti( provider: event.provider, @@ -505,7 +494,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func ensureStatusController() { - if self.statusController != nil { return } + if self.statusController != nil { + return + } if let store, let settings, diff --git a/Sources/CodexBar/PreferencesHooksPane.swift b/Sources/CodexBar/PreferencesHooksPane.swift index 88a8486c81..ccf1eec79d 100644 --- a/Sources/CodexBar/PreferencesHooksPane.swift +++ b/Sources/CodexBar/PreferencesHooksPane.swift @@ -61,6 +61,13 @@ struct HooksPane: View { private struct HookRuleRow: View { @Binding var rule: HookRule let onDelete: () -> Void + @State private var argumentRows: [ArgumentRow] + + init(rule: Binding, onDelete: @escaping () -> Void) { + self._rule = rule + self.onDelete = onDelete + self._argumentRows = State(initialValue: rule.wrappedValue.arguments.map(ArgumentRow.init(value:))) + } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -111,11 +118,48 @@ private struct HookRuleRow: View { .textFieldStyle(.roundedBorder) .font(.system(.caption, design: .monospaced)) - TextField(L("hooks_arguments_placeholder"), text: self.argumentsBinding) - .textFieldStyle(.roundedBorder) - .font(.system(.caption, design: .monospaced)) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L("hooks_arguments_placeholder")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + self.argumentRows.append(ArgumentRow(value: "")) + } label: { + Label(L("hooks_add_argument"), systemImage: "plus") + } + .buttonStyle(.borderless) + .controlSize(.small) + } + + ForEach(self.$argumentRows) { $argument in + HStack { + TextField(L("hooks_argument_placeholder"), text: $argument.value) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + Button { + self.argumentRows.removeAll(where: { $0.id == argument.id }) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(L("hooks_delete_argument")) + } + } + } } .padding(.vertical, 4) + .onChange(of: self.argumentRows.map(\.value)) { _, arguments in + if self.rule.arguments != arguments { + self.rule.arguments = arguments + } + } + .onChange(of: self.rule.arguments) { _, arguments in + if self.argumentRows.map(\.value) != arguments { + self.argumentRows = arguments.map(ArgumentRow.init(value:)) + } + } } private var providerBinding: Binding { @@ -129,10 +173,8 @@ private struct HookRuleRow: View { 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) }) + private struct ArgumentRow: Identifiable { + let id = UUID() + var value: String } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 692e51ba17..00145ae2a2 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -524,7 +524,10 @@ "hooks_threshold" = "التشغيل عند الاستخدام ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "الوسائط (مفصولة بمسافات)"; +"hooks_arguments_placeholder" = "الوسائط"; +"hooks_argument_placeholder" = "الوسيطة"; +"hooks_add_argument" = "إضافة وسيطة"; +"hooks_delete_argument" = "حذف الوسيطة"; "tab_debug" = "تصحيح الأخطاء"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index d8d2fe40f0..39b5bc4d3a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -504,7 +504,10 @@ "hooks_threshold" = "Activa amb ús ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Arguments (separats per espais)"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Afegeix un argument"; +"hooks_delete_argument" = "Elimina l'argument"; "tab_debug" = "Depuració"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index da34973999..173d59a321 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* English localization for CodexBar (base/fallback) */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Hooks aktivieren"; +"hooks_enable_subtitle" = "Externe Befehle bei Kontingent- oder Anbieterereignissen ausführen."; +"hooks_trust_warning" = "Hooks können lokale Befehle auf deinem Mac ausführen. Konfiguriere nur vertrauenswürdige Befehle."; +"hooks_rules_header" = "Regeln"; +"hooks_empty" = "Keine Hooks konfiguriert."; +"hooks_add_rule" = "Regel hinzufügen"; +"hooks_delete_rule" = "Regel löschen"; +"hooks_rule_enabled" = "Aktiviert"; +"hooks_event" = "Ereignis"; +"hooks_provider" = "Anbieter"; +"hooks_any_provider" = "Beliebiger Anbieter"; +"hooks_threshold" = "Auslösen bei Nutzung ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumente"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Argument hinzufügen"; +"hooks_delete_argument" = "Argument löschen"; + " providers" = "Anbieter"; "(System)" = "(System)"; "30d" = "30d"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index bb74b7a36b..f582802c5d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -525,7 +525,10 @@ "hooks_threshold" = "Fire at usage ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Arguments (space-separated)"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Add argument"; +"hooks_delete_argument" = "Delete argument"; /* Providers Pane */ "select_a_provider" = "Select a provider"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 1cc5823804..028fb22d76 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Spanish localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Activar hooks"; +"hooks_enable_subtitle" = "Ejecuta comandos externos cuando ocurran eventos de cuota o proveedor."; +"hooks_trust_warning" = "Los hooks pueden ejecutar comandos locales en tu Mac. Configura solo comandos de confianza."; +"hooks_rules_header" = "Reglas"; +"hooks_empty" = "No hay hooks configurados."; +"hooks_add_rule" = "Añadir regla"; +"hooks_delete_rule" = "Eliminar regla"; +"hooks_rule_enabled" = "Activado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Proveedor"; +"hooks_any_provider" = "Cualquier proveedor"; +"hooks_threshold" = "Ejecutar con uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Añadir argumento"; +"hooks_delete_argument" = "Eliminar argumento"; + " providers" = " proveedores"; "(System)" = "(Sistema)"; "30d" = "30 d"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 4a9833530c..538846f985 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -524,7 +524,10 @@ "hooks_threshold" = "اجرا در مصرف ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "آرگومان‌ها (جدا شده با فاصله)"; +"hooks_arguments_placeholder" = "آرگومان‌ها"; +"hooks_argument_placeholder" = "آرگومان"; +"hooks_add_argument" = "افزودن آرگومان"; +"hooks_delete_argument" = "حذف آرگومان"; "tab_debug" = "اشکال زدایی"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 72b0aaef22..a6a24dcc66 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* English localization for CodexBar (base/fallback) */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Activer les hooks"; +"hooks_enable_subtitle" = "Exécute des commandes externes lors d’événements de quota ou de fournisseur."; +"hooks_trust_warning" = "Les hooks peuvent exécuter des commandes locales sur votre Mac. Ne configurez que des commandes fiables."; +"hooks_rules_header" = "Règles"; +"hooks_empty" = "Aucun hook configuré."; +"hooks_add_rule" = "Ajouter une règle"; +"hooks_delete_rule" = "Supprimer la règle"; +"hooks_rule_enabled" = "Activé"; +"hooks_event" = "Événement"; +"hooks_provider" = "Fournisseur"; +"hooks_any_provider" = "Tout fournisseur"; +"hooks_threshold" = "Déclencher à l’utilisation ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Ajouter un argument"; +"hooks_delete_argument" = "Supprimer l’argument"; + " providers" = " fournisseurs"; "(System)" = "(System)"; "30d" = "30d"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index c914011e76..d40fd21d54 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -498,7 +498,10 @@ "hooks_threshold" = "Activar cun uso ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Argumentos (separados por espazos)"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Engadir argumento"; +"hooks_delete_argument" = "Eliminar argumento"; "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 07d7a65c32..98b04ba064 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -525,7 +525,10 @@ "hooks_threshold" = "Picu saat penggunaan ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Argumen (dipisahkan spasi)"; +"hooks_arguments_placeholder" = "Argumen"; +"hooks_argument_placeholder" = "Argumen"; +"hooks_add_argument" = "Tambah argumen"; +"hooks_delete_argument" = "Hapus argumen"; "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 0517a62519..e33749ec88 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -525,7 +525,10 @@ "hooks_threshold" = "Attiva a utilizzo ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Argomenti (separati da spazi)"; +"hooks_arguments_placeholder" = "Argomenti"; +"hooks_argument_placeholder" = "Argomento"; +"hooks_add_argument" = "Aggiungi argomento"; +"hooks_delete_argument" = "Elimina argomento"; "tab_about" = "Informazioni"; "tab_debug" = "Diagnostica"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 490b2b4c73..aefe7a67b6 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Japanese localization for CodexBar */ +"tab_hooks" = "フック"; +"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" = "引数"; +"hooks_argument_placeholder" = "引数"; +"hooks_add_argument" = "引数を追加"; +"hooks_delete_argument" = "引数を削除"; + " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; "30d" = "30日"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index c12a84440a..d2ea7d9d82 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Korean (한국어) localization for CodexBar */ +"tab_hooks" = "훅"; +"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" = "인수"; +"hooks_argument_placeholder" = "인수"; +"hooks_add_argument" = "인수 추가"; +"hooks_delete_argument" = "인수 삭제"; + " providers" = " 공급자"; "(System)" = "(시스템)"; "30d" = "30일"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index e4d70185f9..75f26de65b 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Dutch localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Hooks inschakelen"; +"hooks_enable_subtitle" = "Voer externe opdrachten uit bij quota- of providergebeurtenissen."; +"hooks_trust_warning" = "Hooks kunnen lokale opdrachten op je Mac uitvoeren. Configureer alleen opdrachten die je vertrouwt."; +"hooks_rules_header" = "Regels"; +"hooks_empty" = "Geen hooks geconfigureerd."; +"hooks_add_rule" = "Regel toevoegen"; +"hooks_delete_rule" = "Regel verwijderen"; +"hooks_rule_enabled" = "Ingeschakeld"; +"hooks_event" = "Gebeurtenis"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Elke provider"; +"hooks_threshold" = "Uitvoeren bij gebruik ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumenten"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Argument toevoegen"; +"hooks_delete_argument" = "Argument verwijderen"; + " providers" = " providers"; "(System)" = "(Systeem)"; "30d" = "30d"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index f653b43036..042de34782 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -525,7 +525,10 @@ "hooks_threshold" = "Uruchom przy użyciu ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "Argumenty (oddzielone spacjami)"; +"hooks_arguments_placeholder" = "Argumenty"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Dodaj argument"; +"hooks_delete_argument" = "Usuń argument"; "tab_about" = "O aplikacji"; "tab_debug" = "Debugowanie"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 2295844532..4900253124 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Brazilian Portuguese localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Ativar hooks"; +"hooks_enable_subtitle" = "Executa comandos externos quando ocorrerem eventos de cota ou provedor."; +"hooks_trust_warning" = "Hooks podem executar comandos locais no seu Mac. Configure apenas comandos confiáveis."; +"hooks_rules_header" = "Regras"; +"hooks_empty" = "Nenhum hook configurado."; +"hooks_add_rule" = "Adicionar regra"; +"hooks_delete_rule" = "Excluir regra"; +"hooks_rule_enabled" = "Ativado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provedor"; +"hooks_any_provider" = "Qualquer provedor"; +"hooks_threshold" = "Executar com uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Adicionar argumento"; +"hooks_delete_argument" = "Excluir argumento"; + " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 745e217c7e..63420c0125 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Russian localization for CodexBar */ +"tab_hooks" = "Хуки"; +"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" = "Аргументы"; +"hooks_argument_placeholder" = "Аргумент"; +"hooks_add_argument" = "Добавить аргумент"; +"hooks_delete_argument" = "Удалить аргумент"; + " providers" = " провайдеров"; "(System)" = "(Система)"; "30d" = "30 дн."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 7241af848d..74eb55feed 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Swedish localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Aktivera hooks"; +"hooks_enable_subtitle" = "Kör externa kommandon vid kvot- eller leverantörshändelser."; +"hooks_trust_warning" = "Hooks kan köra lokala kommandon på din Mac. Konfigurera endast kommandon du litar på."; +"hooks_rules_header" = "Regler"; +"hooks_empty" = "Inga hooks har konfigurerats."; +"hooks_add_rule" = "Lägg till regel"; +"hooks_delete_rule" = "Ta bort regel"; +"hooks_rule_enabled" = "Aktiverad"; +"hooks_event" = "Händelse"; +"hooks_provider" = "Leverantör"; +"hooks_any_provider" = "Valfri leverantör"; +"hooks_threshold" = "Kör vid användning ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argument"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Lägg till argument"; +"hooks_delete_argument" = "Ta bort argument"; + " providers" = " leverantörer"; "(System)" = "(System)"; "30d" = "30 d"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index fa4445edc6..e1e94e0982 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -524,7 +524,10 @@ "hooks_threshold" = "เรียกใช้เมื่อการใช้งาน ≥"; "hooks_threshold_placeholder" = "90"; "hooks_executable_placeholder" = "/usr/local/bin/my-command"; -"hooks_arguments_placeholder" = "อาร์กิวเมนต์ (คั่นด้วยช่องว่าง)"; +"hooks_arguments_placeholder" = "อาร์กิวเมนต์"; +"hooks_argument_placeholder" = "อาร์กิวเมนต์"; +"hooks_add_argument" = "เพิ่มอาร์กิวเมนต์"; +"hooks_delete_argument" = "ลบอาร์กิวเมนต์"; "tab_debug" = "แก้ไขข้อบกพร่อง"; /* Providers Pane */ diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 92b70bed1a..2e1f758ff0 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -523,7 +523,10 @@ "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ış)"; +"hooks_arguments_placeholder" = "Argümanlar"; +"hooks_argument_placeholder" = "Argüman"; +"hooks_add_argument" = "Argüman ekle"; +"hooks_delete_argument" = "Argümanı sil"; "tab_about" = "Hakkında"; "tab_debug" = "Hata Ayıklama"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 77770d861f..abe32a9f31 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Ukrainian localization for CodexBar */ +"tab_hooks" = "Хуки"; +"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" = "Аргументи"; +"hooks_argument_placeholder" = "Аргумент"; +"hooks_add_argument" = "Додати аргумент"; +"hooks_delete_argument" = "Видалити аргумент"; + " providers" = "провайдерів"; "(System)" = "(Система)"; "30d" = "30д"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 765c9d31cb..8e24a02515 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* English localization for CodexBar (base/fallback) */ +"tab_hooks" = "Hook"; +"hooks_enable_title" = "Bật hook"; +"hooks_enable_subtitle" = "Chạy lệnh bên ngoài khi có sự kiện hạn mức hoặc nhà cung cấp."; +"hooks_trust_warning" = "Hook có thể chạy lệnh cục bộ trên máy Mac. Chỉ cấu hình các lệnh bạn tin cậy."; +"hooks_rules_header" = "Quy tắc"; +"hooks_empty" = "Chưa cấu hình hook."; +"hooks_add_rule" = "Thêm quy tắc"; +"hooks_delete_rule" = "Xóa quy tắc"; +"hooks_rule_enabled" = "Đã bật"; +"hooks_event" = "Sự kiện"; +"hooks_provider" = "Nhà cung cấp"; +"hooks_any_provider" = "Nhà cung cấp bất kỳ"; +"hooks_threshold" = "Chạy khi mức sử dụng ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Đối số"; +"hooks_argument_placeholder" = "Đối số"; +"hooks_add_argument" = "Thêm đối số"; +"hooks_delete_argument" = "Xóa đối số"; + " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; "30d" = "30d"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 73f1b63937..2df2fe8222 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Chinese (Simplified) localization for CodexBar */ +"tab_hooks" = "钩子"; +"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" = "参数"; +"hooks_argument_placeholder" = "参数"; +"hooks_add_argument" = "添加参数"; +"hooks_delete_argument" = "删除参数"; + " providers" = " 提供商"; "(System)" = "(System)"; "30d" = "30 天"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 2fca662b20..d79026fbdc 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1,5 +1,25 @@ /* Chinese (Traditional) localization for CodexBar */ +"tab_hooks" = "掛鉤"; +"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" = "引數"; +"hooks_argument_placeholder" = "引數"; +"hooks_add_argument" = "新增引數"; +"hooks_delete_argument" = "刪除引數"; + " providers" = " 提供者"; "(System)" = "(系統)"; "30d" = "30 天"; diff --git a/Sources/CodexBar/UsageStore+LimitResetCelebration.swift b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift index 755b5a7986..02c2ae4d3a 100644 --- a/Sources/CodexBar/UsageStore+LimitResetCelebration.swift +++ b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift @@ -208,6 +208,11 @@ extension UsageStore { ]) switch descriptor.seriesName { case .session: + self.emitQuotaResetHook( + provider: context.provider, + window: .session, + usedPercent: currentUsed, + accountLabel: accountLabel) let event = SessionLimitResetEvent( provider: context.provider, accountIdentifier: accountIdentifier, @@ -215,6 +220,11 @@ extension UsageStore { usedPercent: currentUsed) NotificationCenter.default.post(name: .codexbarSessionLimitReset, object: event) case .weekly: + self.emitQuotaResetHook( + provider: context.provider, + window: .weekly, + usedPercent: currentUsed, + accountLabel: accountLabel) let event = WeeklyLimitResetEvent( provider: context.provider, accountIdentifier: accountIdentifier, diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 7613ea6de5..0b8ee34c2d 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -63,10 +63,59 @@ public enum CodexBarConfigValidator { for entry in config.providers { self.validateProvider(entry, issues: &issues) } + self.validateHooks(config.hooks, issues: &issues) return issues } + private static func validateHooks(_ hooks: HooksConfig?, issues: inout [CodexBarConfigIssue]) { + guard let hooks else { return } + var seenIDs: Set = [] + + for (index, rule) in hooks.events.enumerated() { + let field = "hooks.events[\(index)]" + if !seenIDs.insert(rule.id).inserted { + issues.append(self.hookIssue( + field: field, + code: "duplicate_hook_id", + message: "Hook rule IDs must be unique.")) + } + if !rule.hasValidExecutablePath { + issues.append(self.hookIssue( + field: "\(field).executable", + code: "invalid_hook_executable", + message: "Hook executables must use a non-empty absolute path.")) + } + if !rule.hasKnownProvider { + issues.append(self.hookIssue( + field: "\(field).provider", + code: "invalid_hook_provider", + message: "Hook provider '\(rule.provider ?? "")' is not recognized.")) + } + if !rule.hasValidThreshold { + issues.append(self.hookIssue( + field: "\(field).threshold", + code: "invalid_hook_threshold", + message: "Hook thresholds must be between 0 and 1.")) + } + if !rule.hasValidTimeout { + issues.append(self.hookIssue( + field: "\(field).timeoutSeconds", + code: "invalid_hook_timeout", + message: "Hook timeouts must be between 0.1 and 300 seconds.")) + } + } + } + + private static func hookIssue(field: String, code: String, message: String) -> CodexBarConfigIssue { + CodexBarConfigIssue( + severity: .error, + provider: nil, + field: field, + code: code, + message: message) + } + private static func validateProvider(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) { let provider = entry.id let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) diff --git a/Sources/CodexBarCore/Hooks/HookRule.swift b/Sources/CodexBarCore/Hooks/HookRule.swift index 26068cacb8..0725efe36f 100644 --- a/Sources/CodexBarCore/Hooks/HookRule.swift +++ b/Sources/CodexBarCore/Hooks/HookRule.swift @@ -3,6 +3,9 @@ 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 { + public static let minimumTimeoutSeconds: Double = 0.1 + public static let maximumTimeoutSeconds: Double = 300 + /// Stable identity for SwiftUI list editing; defaults to a fresh UUID string. public var id: String public var enabled: Bool @@ -57,13 +60,36 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { 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 } + guard self.hasValidExecutablePath, self.hasValidTimeout else { return false } + guard self.provider == nil || self.hasKnownProvider else { return false } + guard self.hasValidThreshold 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 } + + public var hasValidExecutablePath: Bool { + !self.executable.isEmpty && (self.executable as NSString).isAbsolutePath + } + + public var hasValidTimeout: Bool { + self.timeoutSeconds.isFinite + && Self.minimumTimeoutSeconds...Self.maximumTimeoutSeconds ~= self.timeoutSeconds + } + + public var hasKnownProvider: Bool { + guard let provider = self.provider else { return true } + return UsageProvider(rawValue: provider) != nil + } + + public var hasValidThreshold: Bool { + guard let threshold = self.threshold else { return true } + return threshold.isFinite && 0...1 ~= threshold + } } public enum QuotaLowHookThreshold { diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 6fe3157a87..d863c78fd1 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -3,6 +3,28 @@ import Foundation import Testing struct ConfigValidationTests { + @Test + func `reports unsafe hook rule fields`() { + let invalidRules = [ + HookRule(id: "duplicate", event: .quotaLow, provider: "unknown", threshold: 1.1, executable: "echo"), + HookRule( + id: "duplicate", + event: .quotaReached, + executable: "/bin/echo", + timeoutSeconds: 301), + ] + let config = CodexBarConfig( + providers: [ProviderConfig(id: .codex)], + hooks: HooksConfig(enabled: true, events: invalidRules)) + let codes = Set(CodexBarConfigValidator.validate(config).map(\.code)) + + #expect(codes.contains("invalid_hook_executable")) + #expect(codes.contains("invalid_hook_provider")) + #expect(codes.contains("invalid_hook_threshold")) + #expect(codes.contains("invalid_hook_timeout")) + #expect(codes.contains("duplicate_hook_id")) + } + @Test func `fresh config defaults Alibaba Token Plan to International`() throws { let config = CodexBarConfig.makeDefault() diff --git a/TestsLinux/HookDispatchTests.swift b/TestsLinux/HookDispatchTests.swift new file mode 100644 index 0000000000..88cee6e53e --- /dev/null +++ b/TestsLinux/HookDispatchTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct HookDispatchTests { + private func event( + _ type: HookEventType = .quotaReached, + provider: String = "codex", + usagePercent: Double? = 0.95, + window: String? = "session") -> HookEvent + { + HookEvent( + event: type, + provider: provider, + window: window, + usagePercent: usagePercent, + resetAt: Date(timeIntervalSince1970: 1_700_000_000), + timestamp: Date(timeIntervalSince1970: 1_700_000_100)) + } + + @Test + func `invalid timeout threshold and provider fail closed`() { + let event = self.event(.quotaLow, provider: "codex", usagePercent: 0.95) + #expect(!HookRule( + event: .quotaLow, + threshold: 1.1, + executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + provider: "unknown", + executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + executable: "/bin/echo", + timeoutSeconds: 0).matches(event)) + } + + @Test + func `runner writes the complete JSON payload to stdin`() async throws { + let original = self.event(.quotaReached, provider: "claude", usagePercent: 0.42, window: "session") + let result = try await HookRunner.run( + rule: HookRule(event: .quotaReached, executable: "/bin/cat"), + event: original) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(HookEvent.self, from: Data(result.stdout.utf8)) + + #expect(decoded == original) + #expect(result.stdout == + "{\"event\":\"quota_reached\",\"provider\":\"claude\",\"resetAt\":\"2023-11-14T22:13:20Z\"," + + "\"timestamp\":\"2023-11-14T22:15:00Z\",\"usagePercent\":0.42,\"window\":\"session\"}") + } + + @Test + func `runner preserves whitespace and empty argument boundaries`() async throws { + let rule = HookRule( + event: .quotaReached, + executable: "/usr/bin/printf", + arguments: ["<%s>|<%s>|<%s>", "quota reached", "", "tail"]) + let result = try await HookRunner.run(rule: rule, event: self.event()) + + #expect(result.stdout == "|<>|") + } + + @Test + func `dispatch coalesces repeated refresh failures`() async throws { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-rate-limit-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let event = self.event(.refreshFailed, usagePercent: nil, window: nil) + let config = HooksConfig(enabled: true, events: [ + HookRule(event: .refreshFailed, executable: "/usr/bin/tee", arguments: ["-a", output.path]), + ]) + let limiter = HookRateLimiter(window: 600) + + await HookRunner.dispatch(event: event, config: config, rateLimiter: limiter) + await HookRunner.dispatch(event: event, config: config, rateLimiter: limiter) + let contents = try String(contentsOf: output, encoding: .utf8) + + #expect(contents.components(separatedBy: "\"event\":\"refresh_failed\"").count - 1 == 1) + } + + @Test + func `dispatch contains one rule failure and continues`() async { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-failure-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let event = self.event() + let config = HooksConfig(enabled: true, events: [ + HookRule(event: .quotaReached, executable: "/nonexistent/codexbar-hook"), + HookRule(event: .quotaReached, executable: "/usr/bin/tee", arguments: [output.path]), + ]) + + await HookRunner.dispatch(event: event, config: config, rateLimiter: HookRateLimiter()) + + #expect(FileManager.default.fileExists(atPath: output.path)) + } + + @Test + func `disabled dispatch never invokes a rule`() async { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-disabled-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let config = HooksConfig(enabled: false, events: [ + HookRule(event: .quotaReached, executable: "/usr/bin/tee", arguments: [output.path]), + ]) + + await HookRunner.dispatch(event: self.event(), config: config, rateLimiter: HookRateLimiter()) + + #expect(!FileManager.default.fileExists(atPath: output.path)) + } +} diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift index 3b3a62d4a9..0dc55740c8 100644 --- a/TestsLinux/HooksTests.swift +++ b/TestsLinux/HooksTests.swift @@ -176,10 +176,10 @@ struct HooksTests { @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 base = ["PATH": "/usr/bin:/bin", "ANTHROPIC_API_KEY": "fixture-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("fixture-should-not-leak")) // secret dropped #expect(!result.stdout.contains("ANTHROPIC_API_KEY")) } diff --git a/docs/cli.md b/docs/cli.md index 4e85468760..11233f48a0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -112,6 +112,11 @@ See `docs/configuration.md` for the schema. - `--format text|json`, `--pretty`, and `--json-only` are supported. - Warnings keep exit code 0; errors exit non-zero. - `codexbar config dump` prints the normalized config JSON. +- `codexbar hooks list` shows the local hook configuration; `--format json` and `--pretty` are supported. +- `codexbar hooks enable|disable` changes the explicit top-level opt-in switch in the local config file. +- `codexbar hooks test --provider ` invokes matching enabled rules with a representative event. Hook + commands run directly without a shell and receive `CODEXBAR_*` variables plus JSON on stdin. See + `docs/configuration.md#external-event-hooks` for the event, payload, timeout, and security contract. ### Token accounts The CLI reads multi-account tokens from the same resolved config file as the app. diff --git a/docs/configuration.md b/docs/configuration.md index c4e58b2a36..ff1f4d1054 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,7 @@ API keys, manual cookie headers, source selection, ordering, and token accounts ```json { "version": 1, + "hooks": null, "providers": [ { "id": "codex", @@ -41,6 +42,69 @@ API keys, manual cookie headers, source selection, ordering, and token accounts } ``` +## External event hooks + +Hooks are local, explicit opt-in automation. Configure them in Settings > Hooks or in this local config file; no +HTTP or remote-config endpoint can create or enable hook rules. The top-level `hooks.enabled` switch defaults to +`false`, and each rule also has its own `enabled` switch. + +```json +{ + "hooks": { + "enabled": true, + "events": [ + { + "id": "quota-alert", + "enabled": true, + "event": "quota_low", + "provider": "codex", + "threshold": 0.9, + "executable": "/usr/local/bin/quota-alert", + "arguments": ["--message", "Codex quota is low", ""], + "timeoutSeconds": 10 + } + ] + } +} +``` + +Commands run as direct executable invocations, never through a shell. `executable` must be an absolute path, +`arguments` preserves exact argument boundaries (including spaces and empty arguments), and `timeoutSeconds` must be +between `0.1` and `300`. Hook processes receive only a small allowlist of general environment variables plus the +event's `CODEXBAR_*` variables; CodexBar provider keys and tokens are not inherited. The same event is also encoded as +JSON on stdin. Only configure executables you trust. + +Events: + +- `quota_low`: a quota lane crosses the rule's `threshold` upward. Thresholds are usage fractions from `0` to `1`; + rules without a threshold use the provider's configured warning thresholds. +- `quota_reached`: the primary session quota crosses into depletion. +- `quota_reset`: a confirmed session or weekly reset occurs. +- `provider_unavailable`: a provider status changes to a minor, major, or critical outage. +- `provider_recovered`: that tracked outage returns to normal. +- `refresh_failed`: a provider refresh fails; `CODEXBAR_STATUS` is a coarse category such as `timeout`, `offline`, + `network_error`, `auth_required`, `cancelled`, or `error`. + +`provider_unavailable` and `refresh_failed` are coalesced per provider/account/window for ten minutes so background +refresh failures cannot create command storms. Quota and recovery events use their transition detectors instead. Hook +failures are contained and never block provider refresh. + +Payload environment variables are `CODEXBAR_EVENT`, `CODEXBAR_PROVIDER`, `CODEXBAR_TIMESTAMP`, and, when available, +`CODEXBAR_ACCOUNT`, `CODEXBAR_WINDOW`, `CODEXBAR_USAGE_PERCENT`, `CODEXBAR_USED`, `CODEXBAR_LIMIT`, +`CODEXBAR_RESET_AT`, and `CODEXBAR_STATUS`. Enabling Hide personal info omits `CODEXBAR_ACCOUNT` and the matching JSON +field. + +The stdin JSON uses the same camel-case field names without the `CODEXBAR_` prefix. Dates are UTC ISO 8601 strings, +usage percentages are `0...1` fractions, unavailable optional fields are omitted rather than encoded as `null`, and +keys are emitted in sorted order. A `quota_reached` payload is exactly shaped like this (the timestamps vary): + +```json +{"event":"quota_reached","provider":"claude","resetAt":"2023-11-14T22:13:20Z","timestamp":"2023-11-14T22:15:00Z","usagePercent":0.42,"window":"session"} +``` + +The v1 field names and meanings are compatibility-stable. Hook consumers should ignore unknown fields so CodexBar can +add optional observability data without breaking existing commands. + ## Provider fields All provider fields are optional unless noted. From 1ae3e88be149728ca1165fe15eeb594a480bc7b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 01:33:26 +0100 Subject: [PATCH 10/13] test: use neutral hook environment fixture --- TestsLinux/HooksTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift index 0dc55740c8..739bfb7eaf 100644 --- a/TestsLinux/HooksTests.swift +++ b/TestsLinux/HooksTests.swift @@ -176,11 +176,11 @@ struct HooksTests { @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": "fixture-should-not-leak"] + let base = ["PATH": "/usr/bin:/bin", "UNRELATED_VARIABLE": "sensitive-fixture"] 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("fixture-should-not-leak")) // secret dropped - #expect(!result.stdout.contains("ANTHROPIC_API_KEY")) + #expect(result.stdout.contains("PATH=/usr/bin:/bin")) // allowlisted variable forwarded + #expect(!result.stdout.contains("sensitive-fixture")) // non-allowlisted value dropped + #expect(!result.stdout.contains("UNRELATED_VARIABLE")) } @Test From 875fe8f0d7a5432b07f7fac598f80fabe0863839 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 01:53:04 +0100 Subject: [PATCH 11/13] fix: bound and stabilize hook execution --- Sources/CodexBar/UsageStore+Hooks.swift | 23 +++++++++ .../CodexBar/UsageStore+QuotaWarnings.swift | 26 ++++++---- Sources/CodexBarCLI/CLIHooksCommand.swift | 48 ++++++++++++++++--- .../Config/CodexBarConfigValidation.swift | 13 +++++ Sources/CodexBarCore/Hooks/HookRule.swift | 22 +++++++-- Sources/CodexBarCore/Hooks/HookRunner.swift | 21 ++++++-- Tests/CodexBarTests/CLIHooksTests.swift | 31 ++++++++++++ .../CodexBarTests/ConfigValidationTests.swift | 15 ++++++ .../QuotaLowHookAccountScopingTests.swift | 48 +++++++++++++++++++ TestsLinux/HookDispatchTests.swift | 25 ++++++++++ docs/cli.md | 3 +- docs/configuration.md | 3 ++ 12 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 Tests/CodexBarTests/CLIHooksTests.swift diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index 76258023c9..16df94898a 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -170,6 +170,29 @@ extension UsageStore { } } + /// Drops baselines while no quota-low rule is active. A later re-enable must + /// establish a fresh sample instead of firing for a crossing that happened + /// while command execution was disabled. + func clearQuotaLowHookUsage(provider: UsageProvider) { + self.quotaLowHookUsage = self.quotaLowHookUsage.filter { $0.key.provider != provider } + } + + /// Extra quota lanes can disappear between snapshots. Forget their baselines + /// so a later reappearance starts fresh rather than reporting a stale crossing. + func pruneQuotaLowHookUsage( + provider: UsageProvider, + accountDiscriminator: String?, + keepingExtraWindowIDs: Set) + { + self.quotaLowHookUsage = self.quotaLowHookUsage.filter { key, _ in + guard key.provider == provider, + key.accountDiscriminator == accountDiscriminator, + let windowID = key.windowID + else { return true } + return keepingExtraWindowIDs.contains(windowID) + } + } + /// 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 057bf1cf36..add7bf17e6 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -51,6 +51,9 @@ extension UsageStore { // 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) + if !hooksActive { + self.clearQuotaLowHookUsage(provider: provider) + } guard notificationsEnabled || hooksActive else { return } if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } @@ -111,16 +114,21 @@ extension UsageStore { rateWindow: secondaryWindow, accountDiscriminator: accountContext.discriminator, accountDisplayName: accountContext.displayName) - 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, - accountDiscriminator: accountContext.discriminator, - accountDisplayName: accountContext.displayName) - } + let extraWindows = provider == .claude + ? (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) + : [] + for named in extraWindows { + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane(window: .weekly, windowID: named.id, label: named.title), + rateWindow: named.window, + accountDiscriminator: accountContext.discriminator, + accountDisplayName: accountContext.displayName) } + self.pruneQuotaLowHookUsage( + provider: provider, + accountDiscriminator: accountContext.discriminator, + keepingExtraWindowIDs: Set(extraWindows.map(\.id))) } } diff --git a/Sources/CodexBarCLI/CLIHooksCommand.swift b/Sources/CodexBarCLI/CLIHooksCommand.swift index ad668ab765..54b177d4dc 100644 --- a/Sources/CodexBarCLI/CLIHooksCommand.swift +++ b/Sources/CodexBarCLI/CLIHooksCommand.swift @@ -108,29 +108,53 @@ extension CodexBarCLI { kind: .config) } - var failures = 0 + var results: [HookTestResult] = [] 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)")") + results.append(HookTestResult( + ruleID: rule.id, + executable: rule.executable, + event: eventType.rawValue, + provider: provider.rawValue, + success: true, + stdout: stdout.isEmpty ? nil : stdout, + error: nil)) + if !output.usesJSONOutput { + print("ran \(rule.executable): OK\(stdout.isEmpty ? "" : " — \(stdout)")") + } } catch { - failures += 1 - Self.writeStderr("ran \(rule.executable): \(error.localizedDescription)\n") + let summary = HookRunner.failureSummary(error) + results.append(HookTestResult( + ruleID: rule.id, + executable: rule.executable, + event: eventType.rawValue, + provider: provider.rawValue, + success: false, + stdout: nil, + error: summary)) + if !output.usesJSONOutput { + Self.writeStderr("ran \(rule.executable): \(summary)\n") + } } } - Self.exit(code: failures == 0 ? .success : .failure, output: output, kind: .runtime) + if output.usesJSONOutput { + Self.printJSON(results, pretty: output.pretty) + } + let succeeded = results.allSatisfy(\.success) + Self.exit(code: succeeded ? .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 { + static func sampleHookEvent(type: HookEventType, provider: String) -> HookEvent { let usagePercent: Double? let status: String? switch type { case .quotaLow, .quotaReached: - usagePercent = 0.95 + usagePercent = 1 status = nil case .quotaReset: usagePercent = 0 @@ -155,6 +179,16 @@ extension CodexBarCLI { } } +struct HookTestResult: Codable, Sendable, Equatable { + let ruleID: String + let executable: String + let event: String + let provider: String + let success: Bool + let stdout: String? + let error: String? +} + struct HooksOptions: CommanderParsable { @Option(name: .long("format"), help: "Output format: text | json") var format: OutputFormat? diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 0b8ee34c2d..31130c997a 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -72,6 +72,13 @@ public enum CodexBarConfigValidator { guard let hooks else { return } var seenIDs: Set = [] + if hooks.events.count > HooksConfig.maximumRuleCount { + issues.append(self.hookIssue( + field: "hooks.events", + code: "too_many_hook_rules", + message: "Hooks support at most \(HooksConfig.maximumRuleCount) rules.")) + } + for (index, rule) in hooks.events.enumerated() { let field = "hooks.events[\(index)]" if !seenIDs.insert(rule.id).inserted { @@ -104,6 +111,12 @@ public enum CodexBarConfigValidator { code: "invalid_hook_timeout", message: "Hook timeouts must be between 0.1 and 300 seconds.")) } + if !rule.hasValidCommandShape { + issues.append(self.hookIssue( + field: field, + code: "invalid_hook_command_size", + message: "Hook IDs, arguments, or aggregate command size exceed supported limits.")) + } } } diff --git a/Sources/CodexBarCore/Hooks/HookRule.swift b/Sources/CodexBarCore/Hooks/HookRule.swift index 0725efe36f..8aa0e51126 100644 --- a/Sources/CodexBarCore/Hooks/HookRule.swift +++ b/Sources/CodexBarCore/Hooks/HookRule.swift @@ -5,6 +5,10 @@ import Foundation public struct HookRule: Codable, Sendable, Equatable, Identifiable { public static let minimumTimeoutSeconds: Double = 0.1 public static let maximumTimeoutSeconds: Double = 300 + public static let maximumIDBytes = 128 + public static let maximumArgumentCount = 32 + public static let maximumStringBytes = 4096 + public static let maximumCommandBytes = 32 * 1024 /// Stable identity for SwiftUI list editing; defaults to a fresh UUID string. public var id: String @@ -60,7 +64,7 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { public func matches(_ event: HookEvent) -> Bool { guard self.enabled else { return false } guard self.event == event.event else { return false } - guard self.hasValidExecutablePath, self.hasValidTimeout else { return false } + guard self.hasValidExecutablePath, self.hasValidTimeout, self.hasValidCommandShape else { return false } guard self.provider == nil || self.hasKnownProvider else { return false } guard self.hasValidThreshold else { return false } if let provider = self.provider, provider != event.provider { @@ -73,7 +77,9 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { } public var hasValidExecutablePath: Bool { - !self.executable.isEmpty && (self.executable as NSString).isAbsolutePath + !self.executable.isEmpty + && self.executable.utf8.count <= Self.maximumStringBytes + && (self.executable as NSString).isAbsolutePath } public var hasValidTimeout: Bool { @@ -90,6 +96,14 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { guard let threshold = self.threshold else { return true } return threshold.isFinite && 0...1 ~= threshold } + + public var hasValidCommandShape: Bool { + guard !self.id.isEmpty, self.id.utf8.count <= Self.maximumIDBytes else { return false } + guard self.arguments.count <= Self.maximumArgumentCount else { return false } + guard self.arguments.allSatisfy({ $0.utf8.count <= Self.maximumStringBytes }) else { return false } + return self.executable.utf8.count + self.arguments.reduce(0) { $0 + $1.utf8.count } + <= Self.maximumCommandBytes + } } public enum QuotaLowHookThreshold { @@ -117,6 +131,8 @@ public enum QuotaLowHookThreshold { /// 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 static let maximumRuleCount = 32 + public var enabled: Bool public var events: [HookRule] @@ -133,7 +149,7 @@ public struct HooksConfig: Codable, Sendable, Equatable { /// Enabled rules that match the event. Returns nothing when hooks are disabled. public func matchingRules(for event: HookEvent) -> [HookRule] { - guard self.enabled else { return [] } + guard self.enabled, self.events.count <= Self.maximumRuleCount else { return [] } return self.events.filter { $0.matches(event) } } } diff --git a/Sources/CodexBarCore/Hooks/HookRunner.swift b/Sources/CodexBarCore/Hooks/HookRunner.swift index ff94ab45e8..359ac38ca9 100644 --- a/Sources/CodexBarCore/Hooks/HookRunner.swift +++ b/Sources/CodexBarCore/Hooks/HookRunner.swift @@ -9,6 +9,7 @@ import Foundation /// environment variables and a JSON stdin payload. public enum HookRunner { private static let log = CodexBarLog.logger(LogCategories.hooks) + public static let maximumPayloadBytes = 4096 /// Environment keys forwarded to a hook. Deliberately narrow: CodexBar's own /// process environment may hold provider API keys/tokens, and hooks must never @@ -32,10 +33,13 @@ public enum HookRunner { 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() + guard payload.count <= Self.maximumPayloadBytes else { + throw HookRunnerError.payloadTooLarge + } + // The checked 4 KiB ceiling keeps this pre-launch write below the pipe + // capacity on supported macOS and Linux systems. The child receives EOF. stdin.fileHandleForWriting.write(payload) try? stdin.fileHandleForWriting.close() @@ -84,13 +88,14 @@ public enum HookRunner { metadata: [ "event": "\(event.event.rawValue)", "provider": "\(event.provider)", - "reason": "\(Self.redactedFailureReason(error))", + "reason": "\(Self.failureSummary(error))", ]) } } } - private static func redactedFailureReason(_ error: Error) -> String { + public static func failureSummary(_ error: Error) -> String { + if error is HookRunnerError { return "payload too large" } guard let error = error as? SubprocessRunnerError else { return "error" } switch error { case .binaryNotFound: return "executable not found" @@ -100,3 +105,11 @@ public enum HookRunner { } } } + +public enum HookRunnerError: LocalizedError, Sendable { + case payloadTooLarge + + public var errorDescription: String? { + "Hook event payload exceeds \(HookRunner.maximumPayloadBytes) bytes." + } +} diff --git a/Tests/CodexBarTests/CLIHooksTests.swift b/Tests/CodexBarTests/CLIHooksTests.swift new file mode 100644 index 0000000000..7459ffb4ff --- /dev/null +++ b/Tests/CodexBarTests/CLIHooksTests.swift @@ -0,0 +1,31 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIHooksTests { + @Test + func `sample quota-low event matches maximum threshold`() { + let event = CodexBarCLI.sampleHookEvent(type: .quotaLow, provider: UsageProvider.codex.rawValue) + let rule = HookRule(event: .quotaLow, threshold: 1, executable: "/bin/echo") + + #expect(event.usagePercent == 1) + #expect(rule.matches(event)) + } + + @Test + func `hook test JSON result is structured`() throws { + let result = HookTestResult( + ruleID: "fixture", + executable: "/bin/echo", + event: "quota_reached", + provider: "codex", + success: true, + stdout: "ok", + error: nil) + let encoded = try #require(CodexBarCLI.encodeJSON([result], pretty: false)) + let decoded = try JSONDecoder().decode([HookTestResult].self, from: Data(encoded.utf8)) + + #expect(decoded == [result]) + } +} diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index d863c78fd1..7c22a6a791 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -25,6 +25,21 @@ struct ConfigValidationTests { #expect(codes.contains("duplicate_hook_id")) } + @Test + func `reports hook workload limits`() { + let oversized = HookRule( + id: String(repeating: "i", count: HookRule.maximumIDBytes + 1), + event: .quotaReached, + executable: "/bin/echo", + arguments: Array(repeating: "x", count: HookRule.maximumArgumentCount + 1)) + let rules = Array(repeating: oversized, count: HooksConfig.maximumRuleCount + 1) + let config = CodexBarConfig(providers: [], hooks: HooksConfig(enabled: true, events: rules)) + let codes = Set(CodexBarConfigValidator.validate(config).map(\.code)) + + #expect(codes.contains("too_many_hook_rules")) + #expect(codes.contains("invalid_hook_command_size")) + } + @Test func `fresh config defaults Alibaba Token Plan to International`() throws { let config = CodexBarConfig.makeDefault() diff --git a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift index 1d69c83fe7..c5ae023917 100644 --- a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift +++ b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +@MainActor struct QuotaLowHookAccountScopingTests { @Test func `quota_low crossing history is scoped per account`() { @@ -35,4 +36,51 @@ struct QuotaLowHookAccountScopingTests { windowID: "claude-weekly-scoped-fable") #expect(Set([session, weekly, scoped]).count == 3) } + + @Test + func `inactive hooks discard quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-inactive") + let claude = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "account") + let codex = UsageStore.QuotaWarningStateKey( + provider: .codex, window: .session, accountDiscriminator: "account") + store.quotaLowHookUsage = [claude: 0.4, codex: 0.5] + + store.clearQuotaLowHookUsage(provider: .claude) + + #expect(store.quotaLowHookUsage[claude] == nil) + #expect(store.quotaLowHookUsage[codex] == 0.5) + } + + @Test + func `vanished extra lanes discard quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-extra") + let retained = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "account", + windowID: "retained") + let vanished = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "account", + windowID: "vanished") + store.quotaLowHookUsage = [retained: 0.4, vanished: 0.5] + + store.pruneQuotaLowHookUsage( + provider: .claude, + accountDiscriminator: "account", + keepingExtraWindowIDs: ["retained"]) + + #expect(store.quotaLowHookUsage[retained] == 0.4) + #expect(store.quotaLowHookUsage[vanished] == nil) + } + + private func makeStore(suiteName: String) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: testSettingsStore(suiteName: suiteName), + environmentBase: [:]) + } } diff --git a/TestsLinux/HookDispatchTests.swift b/TestsLinux/HookDispatchTests.swift index 88cee6e53e..85248e7035 100644 --- a/TestsLinux/HookDispatchTests.swift +++ b/TestsLinux/HookDispatchTests.swift @@ -33,6 +33,16 @@ struct HookDispatchTests { event: .quotaLow, executable: "/bin/echo", timeoutSeconds: 0).matches(event)) + #expect(!HookRule( + event: .quotaReached, + executable: "/bin/echo", + arguments: Array(repeating: "x", count: HookRule.maximumArgumentCount + 1)).matches(event)) + let tooManyRules = HooksConfig( + enabled: true, + events: Array( + repeating: HookRule(event: .quotaReached, executable: "/bin/echo"), + count: HooksConfig.maximumRuleCount + 1)) + #expect(tooManyRules.matchingRules(for: event).isEmpty) } @Test @@ -51,6 +61,21 @@ struct HookDispatchTests { "\"timestamp\":\"2023-11-14T22:15:00Z\",\"usagePercent\":0.42,\"window\":\"session\"}") } + @Test + func `runner rejects payloads above the pipe-safe limit`() async { + let oversized = HookEvent( + event: .quotaReached, + provider: "codex", + account: String(repeating: "x", count: HookRunner.maximumPayloadBytes), + timestamp: Date()) + + await #expect(throws: HookRunnerError.self) { + try await HookRunner.run( + rule: HookRule(event: .quotaReached, executable: "/bin/cat"), + event: oversized) + } + } + @Test func `runner preserves whitespace and empty argument boundaries`() async throws { let rule = HookRule( diff --git a/docs/cli.md b/docs/cli.md index 11233f48a0..b3199b03e8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -115,7 +115,8 @@ See `docs/configuration.md` for the schema. - `codexbar hooks list` shows the local hook configuration; `--format json` and `--pretty` are supported. - `codexbar hooks enable|disable` changes the explicit top-level opt-in switch in the local config file. - `codexbar hooks test --provider ` invokes matching enabled rules with a representative event. Hook - commands run directly without a shell and receive `CODEXBAR_*` variables plus JSON on stdin. See + commands run directly without a shell and receive `CODEXBAR_*` variables plus JSON on stdin. `--format json` and + `--json-only` return structured per-rule results. See `docs/configuration.md#external-event-hooks` for the event, payload, timeout, and security contract. ### Token accounts diff --git a/docs/configuration.md b/docs/configuration.md index ff1f4d1054..b550465f11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,6 +105,9 @@ keys are emitted in sorted order. A `quota_reached` payload is exactly shaped li The v1 field names and meanings are compatibility-stable. Hook consumers should ignore unknown fields so CodexBar can add optional observability data without breaking existing commands. +Safety limits: at most 32 rules, 32 arguments per rule, 4 KiB per executable or argument string, 32 KiB per command, +and 4 KiB per event payload. Configurations beyond these limits fail closed and do not execute. + ## Provider fields All provider fields are optional unless noted. From ff6fb83dd71e39206827d9604160a992f7d00d23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 02:04:18 +0100 Subject: [PATCH 12/13] fix: invalidate stale quota hook state --- Sources/CodexBar/UsageStore+Hooks.swift | 24 +++++++++++++++---- .../CodexBar/UsageStore+QuotaWarnings.swift | 1 + Sources/CodexBar/UsageStore.swift | 1 + .../Config/CodexBarConfigValidation.swift | 2 +- Sources/CodexBarCore/Hooks/HookRule.swift | 2 +- .../QuotaLowHookAccountScopingTests.swift | 15 ++++++++++++ TestsLinux/HookDispatchTests.swift | 4 ++++ docs/configuration.md | 3 ++- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift index 16df94898a..c9058ee29b 100644 --- a/Sources/CodexBar/UsageStore+Hooks.swift +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -17,12 +17,15 @@ extension UsageStore { status: String? = nil, accountDisplayName: String? = nil) { - guard let hooks = self.settings.config.hooks, hooks.enabled else { return } + guard let hooks = self.settings.config.hooks, + hooks.enabled, + hooks.events.count <= HooksConfig.maximumRuleCount + else { return } let event = HookEvent( event: type, provider: provider.rawValue, - account: accountDisplayName, + account: self.settings.hidePersonalInfo ? nil : accountDisplayName, window: window, usagePercent: usagePercent, resetAt: resetAt, @@ -116,7 +119,10 @@ extension UsageStore { accountDiscriminator: String?, accountDisplayName: String?) { - guard let hooks = self.settings.config.hooks, hooks.enabled else { return } + guard let hooks = self.settings.config.hooks, + hooks.enabled, + hooks.events.count <= HooksConfig.maximumRuleCount + else { return } let rules = hooks.events.filter { rule in rule.enabled && rule.event == .quotaLow @@ -153,7 +159,7 @@ extension UsageStore { let event = HookEvent( event: .quotaLow, provider: provider.rawValue, - account: accountDisplayName, + account: self.settings.hidePersonalInfo ? nil : accountDisplayName, window: lane.label, usagePercent: current, resetAt: rateWindow.resetsAt, @@ -177,6 +183,16 @@ extension UsageStore { self.quotaLowHookUsage = self.quotaLowHookUsage.filter { $0.key.provider != provider } } + /// Any persisted config edit can include a hook disable/re-enable or rule + /// replacement. Reset crossing baselines on the next sample so transitions + /// that occurred while the prior configuration was inactive never execute. + func resetQuotaLowHookUsageIfConfigurationChanged() { + let revision = self.settings.configRevision + guard self.quotaLowHookConfigRevision != revision else { return } + self.quotaLowHookUsage.removeAll() + self.quotaLowHookConfigRevision = revision + } + /// Extra quota lanes can disappear between snapshots. Forget their baselines /// so a later reappearance starts fresh rather than reporting a stale crossing. func pruneQuotaLowHookUsage( diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index add7bf17e6..fce4cc9a4d 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -50,6 +50,7 @@ extension UsageStore { // Hooks have their own enable switch and per-rule thresholds, so quota_low // hooks run on a separate path that does not depend on the notification // preference or the notification thresholds. + self.resetQuotaLowHookUsageIfConfigurationChanged() let hooksActive = self.hasQuotaHookRule(event: .quotaLow, provider: provider) if !hooksActive { self.clearQuotaLowHookUsage(provider: provider) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6056a18a3a..f374152293 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -342,6 +342,7 @@ final class UsageStore { /// Last observed usage fraction (0...1) per account and quota-warning lane, used /// to detect upward crossings of a quota_low hook rule's own threshold. @ObservationIgnored var quotaLowHookUsage: [QuotaWarningStateKey: Double] = [:] + @ObservationIgnored var quotaLowHookConfigRevision: Int? @ObservationIgnored var predictivePaceWarningNotifiedKeys: Set = [] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 31130c997a..42d5e45bce 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -103,7 +103,7 @@ public enum CodexBarConfigValidator { issues.append(self.hookIssue( field: "\(field).threshold", code: "invalid_hook_threshold", - message: "Hook thresholds must be between 0 and 1.")) + message: "Hook thresholds must be greater than 0 and at most 1.")) } if !rule.hasValidTimeout { issues.append(self.hookIssue( diff --git a/Sources/CodexBarCore/Hooks/HookRule.swift b/Sources/CodexBarCore/Hooks/HookRule.swift index 8aa0e51126..db2c18768b 100644 --- a/Sources/CodexBarCore/Hooks/HookRule.swift +++ b/Sources/CodexBarCore/Hooks/HookRule.swift @@ -94,7 +94,7 @@ public struct HookRule: Codable, Sendable, Equatable, Identifiable { public var hasValidThreshold: Bool { guard let threshold = self.threshold else { return true } - return threshold.isFinite && 0...1 ~= threshold + return threshold.isFinite && threshold > 0 && threshold <= 1 } public var hasValidCommandShape: Bool { diff --git a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift index c5ae023917..30462a4ee0 100644 --- a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift +++ b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift @@ -52,6 +52,21 @@ struct QuotaLowHookAccountScopingTests { #expect(store.quotaLowHookUsage[codex] == 0.5) } + @Test + func `configuration revision discards quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-revision") + let key = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "account") + store.resetQuotaLowHookUsageIfConfigurationChanged() + store.quotaLowHookUsage[key] = 0.4 + + store.settings.setHooksEnabled(true) + store.resetQuotaLowHookUsageIfConfigurationChanged() + + #expect(store.quotaLowHookUsage[key] == nil) + #expect(store.quotaLowHookConfigRevision == store.settings.configRevision) + } + @Test func `vanished extra lanes discard quota-low baselines`() { let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-extra") diff --git a/TestsLinux/HookDispatchTests.swift b/TestsLinux/HookDispatchTests.swift index 85248e7035..4626594bc2 100644 --- a/TestsLinux/HookDispatchTests.swift +++ b/TestsLinux/HookDispatchTests.swift @@ -25,6 +25,10 @@ struct HookDispatchTests { event: .quotaLow, threshold: 1.1, executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + threshold: 0, + executable: "/bin/echo").matches(event)) #expect(!HookRule( event: .quotaLow, provider: "unknown", diff --git a/docs/configuration.md b/docs/configuration.md index b550465f11..abc76f32dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,7 +76,8 @@ JSON on stdin. Only configure executables you trust. Events: -- `quota_low`: a quota lane crosses the rule's `threshold` upward. Thresholds are usage fractions from `0` to `1`; +- `quota_low`: a quota lane crosses the rule's `threshold` upward. Thresholds are usage fractions greater than `0` + and at most `1`; rules without a threshold use the provider's configured warning thresholds. - `quota_reached`: the primary session quota crosses into depletion. - `quota_reset`: a confirmed session or weekly reset occurs. From 1e0cf756039cb5f76b54e55feaa1e4e732425153 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 06:31:02 +0100 Subject: [PATCH 13/13] fix: align hook test failure status --- Sources/CodexBarCLI/CLIHooksCommand.swift | 2 +- Tests/CodexBarTests/CLIHooksTests.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/CLIHooksCommand.swift b/Sources/CodexBarCLI/CLIHooksCommand.swift index 54b177d4dc..0279884a7f 100644 --- a/Sources/CodexBarCLI/CLIHooksCommand.swift +++ b/Sources/CodexBarCLI/CLIHooksCommand.swift @@ -167,7 +167,7 @@ extension CodexBarCLI { status = "none" case .refreshFailed: usagePercent = nil - status = "test failure" + status = "error" } return HookEvent( event: type, diff --git a/Tests/CodexBarTests/CLIHooksTests.swift b/Tests/CodexBarTests/CLIHooksTests.swift index 7459ffb4ff..ea74142613 100644 --- a/Tests/CodexBarTests/CLIHooksTests.swift +++ b/Tests/CodexBarTests/CLIHooksTests.swift @@ -13,6 +13,13 @@ struct CLIHooksTests { #expect(rule.matches(event)) } + @Test + func `sample refresh failure uses production status`() { + let event = CodexBarCLI.sampleHookEvent(type: .refreshFailed, provider: UsageProvider.codex.rawValue) + + #expect(event.status == "error") + } + @Test func `hook test JSON result is structured`() throws { let result = HookTestResult(