Skip to main content

zeph_core/
notifications.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Best-effort per-turn completion notifier.
5//!
6//! Fires after each agent turn completes via two independent channels:
7//! - **macOS native** — `osascript` banner via stdin (no argument-injection risk)
8//! - **ntfy webhook** — JSON POST to an ntfy-compatible endpoint
9//!
10//! All notifications are fire-and-forget: failures are logged at `warn` level and
11//! never propagated to the caller. Secrets are redacted before any payload leaves
12//! the process.
13//!
14//! # Gating
15//!
16//! [`Notifier::should_fire`] applies all gate conditions in order:
17//! 1. Master `enabled` switch must be `true`
18//! 2. `llm_requests == 0` → skip (slash commands, cache-only, security-blocked turns)
19//! 3. `only_on_error && !is_error` → skip
20//! 4. Duration gate (`min_turn_duration_ms`) applies only to successful turns;
21//!    error turns always fire regardless of duration
22//!
23//! # Examples
24//!
25//! ```no_run
26//! use zeph_core::notifications::{Notifier, TurnSummary, TurnExitStatus};
27//! use zeph_config::NotificationsConfig;
28//!
29//! let cfg = NotificationsConfig {
30//!     enabled: true,
31//!     macos_native: true,
32//!     ..Default::default()
33//! };
34//! let notifier = Notifier::new(cfg);
35//! let summary = TurnSummary {
36//!     duration_ms: 5000,
37//!     preview: "Done. Files updated.".to_owned(),
38//!     tool_calls: 2,
39//!     llm_requests: 1,
40//!     exit_status: TurnExitStatus::Success,
41//! };
42//! // notifier.fire(&summary, supervisor) — called from the agent loop with
43//! // its BackgroundSupervisor; fire-and-forget, errors are logged only.
44//! let _ = notifier.should_fire(&summary); // gate check
45//! ```
46
47use std::time::Duration;
48
49use serde::Serialize;
50use tracing::warn;
51use zeph_config::NotificationsConfig;
52
53use crate::agent::agent_supervisor::{BackgroundSupervisor, TaskClass};
54use crate::redact::scrub_content;
55
56// ── Public types ─────────────────────────────────────────────────────────────
57
58#[non_exhaustive]
59/// Whether a turn completed successfully or with an error.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TurnExitStatus {
62    /// Turn completed without error.
63    Success,
64    /// Turn completed with an error (tool failure, LLM error, etc.).
65    Error,
66}
67
68/// Lightweight summary of a completed agent turn used as notification input.
69///
70/// Built by the agent loop after `channel.flush_chunks()` and passed to
71/// `Notifier::fire`. Contains only what is needed for gate decisions and
72/// notification body assembly — no LLM payloads or raw tool outputs.
73#[derive(Debug, Clone)]
74pub struct TurnSummary {
75    /// Total wall-clock duration of the turn in milliseconds.
76    pub duration_ms: u64,
77    /// First ≤ 160 chars of the assistant response, already redacted by the caller.
78    pub preview: String,
79    /// Number of tool calls dispatched this turn.
80    pub tool_calls: u32,
81    /// Number of completed LLM round-trips this turn.
82    /// Zero for slash commands, cache-only turns, and security-blocked inputs.
83    pub llm_requests: u32,
84    /// Whether the turn ended with an error.
85    pub exit_status: TurnExitStatus,
86}
87
88/// Per-turn completion notifier.
89///
90/// Holds a shared [`reqwest::Client`] and the resolved config. Construct once at
91/// agent startup via [`Notifier::new`] and call `Notifier::fire` after each turn.
92///
93/// All I/O is routed through the agent's `BackgroundSupervisor` so it is
94/// visible in TUI status and bounded by the Telemetry class concurrency limit.
95/// `fire` returns immediately without blocking the agent loop.
96///
97/// Cloning is cheap — `reqwest::Client` is an `Arc`-backed handle.
98#[derive(Clone)]
99pub struct Notifier {
100    cfg: NotificationsConfig,
101    http: reqwest::Client,
102}
103
104impl Notifier {
105    /// Create a notifier from a [`NotificationsConfig`].
106    ///
107    /// Constructs a shared HTTP client with a 5-second connect timeout. The client
108    /// is reused across all webhook calls for the agent session.
109    ///
110    /// If `webhook_url` is set but fails URL validation (unparseable or non-HTTP(S)
111    /// scheme), it is cleared to `None` and a warning is logged. This prevents SSRF
112    /// via malformed URLs (e.g. `file://`, `ftp://`).
113    #[must_use]
114    pub fn new(cfg: NotificationsConfig) -> Self {
115        let http = reqwest::Client::builder()
116            .connect_timeout(Duration::from_secs(5))
117            .timeout(Duration::from_secs(5))
118            .build()
119            .unwrap_or_default();
120        let mut cfg = cfg;
121        if cfg
122            .webhook_url
123            .as_deref()
124            .is_some_and(|url| !validate_webhook_url(url, cfg.webhook_allow_insecure))
125        {
126            cfg.webhook_url = None;
127        }
128        Self { cfg, http }
129    }
130
131    /// Evaluate all gate conditions and return `true` when the notification should fire.
132    ///
133    /// Gates applied in order (all must pass):
134    /// 1. `enabled` is `true`
135    /// 2. `summary.llm_requests > 0` (zero-LLM turns are never notified)
136    /// 3. If `only_on_error`: turn must have errored
137    /// 4. For successful turns: `duration_ms >= min_turn_duration_ms`
138    ///    (error turns bypass the duration gate)
139    #[must_use]
140    pub fn should_fire(&self, summary: &TurnSummary) -> bool {
141        if !self.cfg.enabled {
142            return false;
143        }
144        // Gate S6: never notify for zero-LLM turns (slash commands, cache hits, etc.)
145        // Exception M8 from critic: allow zero-LLM errors through so setup failures surface.
146        if summary.llm_requests == 0 && summary.exit_status == TurnExitStatus::Success {
147            return false;
148        }
149        match summary.exit_status {
150            // Gate S4: errors always fire, bypassing the duration gate.
151            TurnExitStatus::Error => true,
152            TurnExitStatus::Success => {
153                if self.cfg.only_on_error {
154                    return false;
155                }
156                // Duration gate applies only to successful turns.
157                summary.duration_ms >= self.cfg.min_turn_duration_ms
158            }
159        }
160    }
161
162    /// Fire all enabled notification channels for this turn summary.
163    ///
164    /// Returns immediately — all I/O is routed through `supervisor` as a
165    /// [`TaskClass::Telemetry`] task, making it visible in TUI status and
166    /// bounded by the class concurrency limit. Failures are logged at `warn`
167    /// level and never propagated.
168    pub(crate) fn fire(&self, summary: &TurnSummary, supervisor: &mut BackgroundSupervisor) {
169        let cfg = self.cfg.clone();
170        let http = self.http.clone();
171        let summary = summary.clone();
172
173        supervisor.spawn(TaskClass::Telemetry, "notify_turn_complete", async move {
174            fire_all_channels(&cfg, &http, &summary).await;
175        });
176    }
177
178    /// Fire a test notification with a fixed message.
179    ///
180    /// Used by the `zeph notify test` CLI subcommand. Returns an error if all
181    /// channels are disabled or if every channel failed.
182    ///
183    /// # Errors
184    ///
185    /// - `NotifyTestError::AllDisabled` — no channel is enabled
186    /// - `NotifyTestError::MacOsFailed` — macOS notification failed (macOS only)
187    /// - `NotifyTestError::WebhookFailed` — webhook POST failed
188    pub async fn fire_test(&self) -> Result<(), NotifyTestError> {
189        if !self.cfg.enabled {
190            return Err(NotifyTestError::MasterSwitchDisabled);
191        }
192
193        let macos_enabled = self.cfg.macos_native;
194        let webhook_enabled = self.cfg.webhook_url.is_some() && self.cfg.webhook_topic.is_some();
195
196        if !macos_enabled && !webhook_enabled {
197            return Err(NotifyTestError::AllDisabled);
198        }
199
200        let summary = TurnSummary {
201            duration_ms: 0,
202            preview: "Zeph is working".to_owned(),
203            tool_calls: 0,
204            llm_requests: 1,
205            exit_status: TurnExitStatus::Success,
206        };
207
208        #[cfg(target_os = "macos")]
209        if macos_enabled {
210            fire_macos_native(&self.cfg.title, "Zeph is working")
211                .await
212                .map_err(|e| NotifyTestError::MacOsFailed(e.to_string()))?;
213        }
214
215        if let (Some(url), Some(topic)) = (&self.cfg.webhook_url, &self.cfg.webhook_topic) {
216            fire_webhook(&self.http, url, &self.cfg.title, topic, &summary)
217                .await
218                .map_err(|e| NotifyTestError::WebhookFailed(e.to_string()))?;
219        }
220
221        Ok(())
222    }
223}
224
225#[non_exhaustive]
226/// Error returned by [`Notifier::fire_test`].
227#[derive(Debug, thiserror::Error)]
228pub enum NotifyTestError {
229    /// The master `notifications.enabled` switch is `false`.
230    #[error("notifications are disabled (set notifications.enabled = true to enable)")]
231    MasterSwitchDisabled,
232    /// No channels are enabled in the current configuration.
233    #[error("all notification channels are disabled")]
234    AllDisabled,
235    /// macOS notification failed.
236    #[error("macOS notification failed: {0}")]
237    MacOsFailed(String),
238    /// Webhook POST failed.
239    #[error("webhook notification failed: {0}")]
240    WebhookFailed(String),
241}
242
243// ── Internal helpers ──────────────────────────────────────────────────────────
244
245/// Fire all enabled channels for `summary`. Called from a spawned task.
246async fn fire_all_channels(
247    cfg: &NotificationsConfig,
248    http: &reqwest::Client,
249    summary: &TurnSummary,
250) {
251    let title = &cfg.title;
252
253    #[cfg(target_os = "macos")]
254    {
255        let message = build_notification_message(summary);
256        if cfg.macos_native
257            && let Err(e) = fire_macos_native(title, &message).await
258        {
259            warn!(error = %e, "macOS notification failed");
260        }
261    }
262
263    if let (Some(url), Some(topic)) = (&cfg.webhook_url, &cfg.webhook_topic)
264        && let Err(e) = fire_webhook(http, url, title, topic, summary).await
265    {
266        warn!(error = %e, "webhook notification failed");
267    }
268}
269
270/// Build the notification body from a turn summary, applying secret redaction.
271fn build_notification_message(summary: &TurnSummary) -> String {
272    let status = if summary.exit_status == TurnExitStatus::Error {
273        "Error"
274    } else {
275        "Done"
276    };
277
278    // Apply scrub_content to redact any secrets that may be in the preview.
279    let safe_preview = scrub_content(&summary.preview);
280
281    if safe_preview.is_empty() {
282        format!("{status} — {dur}ms", dur = summary.duration_ms)
283    } else {
284        format!(
285            "{status} — {dur}ms\n{preview}",
286            dur = summary.duration_ms,
287            preview = safe_preview,
288        )
289    }
290}
291
292/// Sanitize a string for safe inclusion inside an `AppleScript` `"..."` literal.
293///
294/// Steps applied in order (order is important):
295/// 1. Replace all ASCII control characters (< 0x20) and Unicode control chars with space
296///    (tab `\t` is also replaced — single-line banners only)
297/// 2. Replace newlines `\n` and carriage returns `\r` with a single space
298/// 3. Truncate to `max` chars, appending `…` when cut
299/// 4. Strip `\` and `"` — `AppleScript` does not support backslash escaping inside strings,
300///    so these characters must be removed to prevent injection
301///
302/// # Examples
303///
304/// ```
305/// # use zeph_core::notifications::sanitize_applescript_payload;
306/// let s = sanitize_applescript_payload("Hello\nWorld\"", 200);
307/// assert_eq!(s, "Hello World");
308/// ```
309#[must_use]
310pub fn sanitize_applescript_payload(s: &str, max: usize) -> String {
311    // Step 1 + 2: normalise control characters and Unicode line/paragraph separators.
312    // U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are not in the Unicode Cc
313    // category but still break AppleScript string literals, so they are explicitly replaced.
314    let cleaned: String = s
315        .chars()
316        .map(|c| {
317            if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
318                ' '
319            } else {
320                c
321            }
322        })
323        .collect();
324    // Step 3: truncate to `max` char count (not bytes).
325    let char_count = cleaned.chars().count();
326    let truncated: String = if char_count > max {
327        let end = cleaned
328            .char_indices()
329            .nth(max)
330            .map_or(cleaned.len(), |(i, _)| i);
331        let mut t = cleaned[..end].to_owned();
332        t.push('…');
333        t
334    } else {
335        cleaned
336    };
337    // Step 4: strip characters that cannot be safely embedded in an AppleScript string literal.
338    // AppleScript does not use backslash escaping inside strings — the only safe approach
339    // is to remove double-quote and backslash characters entirely.
340    truncated.replace(['\\', '"'], "")
341}
342
343/// Validate a webhook URL for safe use as a notification endpoint.
344///
345/// Returns `true` when the URL is acceptable. Logs a warning and returns `false`
346/// when the URL is unparseable or uses a non-HTTP(S) scheme. Accepts `http://`
347/// only when `allow_insecure` is `true` (opt-in for local testing).
348fn validate_webhook_url(url: &str, allow_insecure: bool) -> bool {
349    match url.parse::<reqwest::Url>() {
350        Ok(parsed) => {
351            if parsed.scheme() == "https" {
352                return true;
353            }
354            if allow_insecure && parsed.scheme() == "http" {
355                warn!(
356                    "webhook_url uses insecure HTTP scheme; set webhook_allow_insecure=false for production"
357                );
358                return true;
359            }
360            warn!(
361                scheme = parsed.scheme(),
362                "webhook_url has non-HTTP(S) scheme — channel disabled"
363            );
364            false
365        }
366        Err(e) => {
367            warn!(error = %e, "webhook_url is not a valid URL — channel disabled");
368            false
369        }
370    }
371}
372
373/// Fire a macOS Notification Center banner via osascript (stdin-fed to avoid arg injection).
374#[cfg(target_os = "macos")]
375async fn fire_macos_native(
376    title: &str,
377    message: &str,
378) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
379    use tokio::io::AsyncWriteExt as _;
380    use tokio::process::Command;
381
382    let safe_title = sanitize_applescript_payload(title, 120);
383    let safe_message = sanitize_applescript_payload(message, 240);
384
385    let script = format!(r#"display notification "{safe_message}" with title "{safe_title}""#);
386
387    let mut child = Command::new("osascript")
388        .stdin(std::process::Stdio::piped())
389        .stdout(std::process::Stdio::null())
390        .stderr(std::process::Stdio::null())
391        .spawn()?;
392
393    if let Some(mut stdin) = child.stdin.take() {
394        stdin.write_all(script.as_bytes()).await?;
395        stdin.shutdown().await?;
396    }
397
398    // Wait up to 5s for osascript to complete; ignore exit status (best-effort).
399    let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
400
401    Ok(())
402}
403
404/// ntfy-compatible JSON webhook body.
405///
406/// Matches the [ntfy publish-as-JSON](https://docs.ntfy.sh/publish/#publish-as-json) schema.
407#[derive(Serialize)]
408struct NtfyWebhookBody<'a> {
409    topic: &'a str,
410    title: &'a str,
411    message: &'a str,
412    tags: Vec<&'a str>,
413    /// Priority 1–5. Default 3; error turns use 4.
414    priority: u8,
415}
416
417/// POST a notification to an ntfy-compatible JSON endpoint.
418async fn fire_webhook(
419    client: &reqwest::Client,
420    url: &str,
421    title: &str,
422    topic: &str,
423    summary: &TurnSummary,
424) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
425    let message = build_notification_message(summary);
426    let (tags, priority) = if summary.exit_status == TurnExitStatus::Error {
427        (vec!["zeph", "error"], 4u8)
428    } else {
429        (vec!["zeph", "turn-complete"], 3u8)
430    };
431
432    let body = NtfyWebhookBody {
433        topic,
434        title,
435        message: &message,
436        tags,
437        priority,
438    };
439
440    // Timeout is already set on the client (5s), but wrap for clarity.
441    tokio::time::timeout(Duration::from_secs(5), client.post(url).json(&body).send())
442        .await??
443        .error_for_status()?;
444
445    Ok(())
446}
447
448// ── Tests ─────────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use zeph_config::NotificationsConfig;
454
455    fn make_notifier(cfg: NotificationsConfig) -> Notifier {
456        Notifier::new(cfg)
457    }
458
459    fn success_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
460        TurnSummary {
461            duration_ms,
462            preview: "All done.".to_owned(),
463            tool_calls: 0,
464            llm_requests,
465            exit_status: TurnExitStatus::Success,
466        }
467    }
468
469    fn error_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
470        TurnSummary {
471            duration_ms,
472            preview: "Error occurred.".to_owned(),
473            tool_calls: 0,
474            llm_requests,
475            exit_status: TurnExitStatus::Error,
476        }
477    }
478
479    // ── should_fire gate tests ────────────────────────────────────────────────
480
481    #[test]
482    fn should_fire_disabled_master_switch() {
483        let n = make_notifier(NotificationsConfig {
484            enabled: false,
485            ..Default::default()
486        });
487        assert!(!n.should_fire(&success_summary(5000, 1)));
488    }
489
490    #[test]
491    fn should_fire_zero_llm_success_skipped() {
492        let n = make_notifier(NotificationsConfig {
493            enabled: true,
494            ..Default::default()
495        });
496        // Zero-LLM successful turns (slash commands, cache hits) are never notified.
497        assert!(!n.should_fire(&success_summary(0, 0)));
498    }
499
500    #[test]
501    fn should_fire_zero_llm_error_fires() {
502        // Critic M8: zero-LLM errors (setup failures) should still fire.
503        let n = make_notifier(NotificationsConfig {
504            enabled: true,
505            ..Default::default()
506        });
507        assert!(n.should_fire(&error_summary(0, 0)));
508    }
509
510    #[test]
511    fn should_fire_only_on_error_skips_success() {
512        let n = make_notifier(NotificationsConfig {
513            enabled: true,
514            only_on_error: true,
515            ..Default::default()
516        });
517        assert!(!n.should_fire(&success_summary(5000, 1)));
518    }
519
520    #[test]
521    fn should_fire_only_on_error_fires_on_error() {
522        let n = make_notifier(NotificationsConfig {
523            enabled: true,
524            only_on_error: true,
525            ..Default::default()
526        });
527        assert!(n.should_fire(&error_summary(100, 1)));
528    }
529
530    #[test]
531    fn should_fire_duration_gate_success_below_threshold() {
532        let n = make_notifier(NotificationsConfig {
533            enabled: true,
534            min_turn_duration_ms: 3000,
535            ..Default::default()
536        });
537        assert!(!n.should_fire(&success_summary(2999, 1)));
538    }
539
540    #[test]
541    fn should_fire_duration_gate_success_at_threshold() {
542        let n = make_notifier(NotificationsConfig {
543            enabled: true,
544            min_turn_duration_ms: 3000,
545            ..Default::default()
546        });
547        assert!(n.should_fire(&success_summary(3000, 1)));
548    }
549
550    #[test]
551    fn should_fire_error_bypasses_duration_gate() {
552        // Gate S4: errors always fire even when below min_turn_duration_ms.
553        let n = make_notifier(NotificationsConfig {
554            enabled: true,
555            min_turn_duration_ms: 3000,
556            ..Default::default()
557        });
558        assert!(n.should_fire(&error_summary(100, 1)));
559    }
560
561    // ── sanitize_applescript_payload tests ────────────────────────────────────
562
563    #[test]
564    fn sanitize_control_chars_replaced_with_space() {
565        let result = sanitize_applescript_payload("Hello\nWorld", 200);
566        // Newline becomes space, no quotes broken
567        assert!(!result.contains('\n'));
568        assert!(result.contains("Hello World"));
569    }
570
571    #[test]
572    fn sanitize_quotes_stripped() {
573        // AppleScript has no backslash escape — double quotes are stripped entirely.
574        let result = sanitize_applescript_payload(r#"say "hi""#, 200);
575        assert!(!result.contains('"'));
576        assert_eq!(result, "say hi");
577    }
578
579    #[test]
580    fn sanitize_backslash_stripped() {
581        // AppleScript has no backslash escape — backslashes are stripped entirely.
582        let result = sanitize_applescript_payload(r"C:\Users\foo", 200);
583        assert_eq!(result, "C:Usersfoo");
584    }
585
586    #[test]
587    fn sanitize_truncation_appends_ellipsis() {
588        let long = "a".repeat(300);
589        let result = sanitize_applescript_payload(&long, 200);
590        assert!(result.ends_with('…'));
591        // Char count should be max + 1 for the ellipsis.
592        assert_eq!(result.chars().count(), 201);
593    }
594
595    #[test]
596    fn sanitize_no_truncation_when_short() {
597        let result = sanitize_applescript_payload("short", 200);
598        assert_eq!(result, "short");
599    }
600
601    #[test]
602    fn sanitize_injection_attempt() {
603        // Classic AppleScript injection via closing the string and calling display dialog.
604        let payload = r#""; display dialog "gotcha"; ""#;
605        let result = sanitize_applescript_payload(payload, 200);
606        // All `"` must be escaped; the script cannot terminate the outer string.
607        assert!(!result.contains('"'));
608    }
609
610    #[test]
611    fn sanitize_applescript_payload_empty() {
612        assert_eq!(sanitize_applescript_payload("", 200), "");
613    }
614
615    #[test]
616    fn sanitize_tab_replaced() {
617        let result = sanitize_applescript_payload("a\tb", 200);
618        assert_eq!(result, "a b");
619    }
620
621    #[test]
622    fn sanitize_line_separators() {
623        let s = "hello\u{2028}world\u{2029}end";
624        let result = sanitize_applescript_payload(s, 200);
625        assert!(!result.contains('\u{2028}'));
626        assert!(!result.contains('\u{2029}'));
627        assert_eq!(result, "hello world end");
628    }
629
630    // ── build_notification_message tests ──────────────────────────────────────
631
632    #[test]
633    fn notification_message_success() {
634        let summary = success_summary(1234, 1);
635        let msg = build_notification_message(&summary);
636        assert!(msg.starts_with("Done"));
637        assert!(msg.contains("1234ms"));
638    }
639
640    #[test]
641    fn notification_message_error() {
642        let summary = error_summary(500, 1);
643        let msg = build_notification_message(&summary);
644        assert!(msg.starts_with("Error"));
645    }
646
647    #[test]
648    fn notification_message_redacts_secrets() {
649        let summary = TurnSummary {
650            duration_ms: 100,
651            preview: "Done. Key: sk-abc123xyz".to_owned(),
652            tool_calls: 0,
653            llm_requests: 1,
654            exit_status: TurnExitStatus::Success,
655        };
656        let msg = build_notification_message(&summary);
657        assert!(!msg.contains("sk-abc123xyz"), "secret must be redacted");
658        assert!(
659            msg.contains("[REDACTED]"),
660            "should contain redaction marker"
661        );
662    }
663}