diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 3ef6c7e76..f87b4a652 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -367,6 +367,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))), FocusMode: notify.FocusAlways, }) + // Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely + // log a failed delivery to stderr (never stdout). The sink redacts before + // logging, so a token in the URL or message is masked. + notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) { + fmt.Fprintf(stderr, "[notify] "+format+"\n", args...) + }) if options.useSpec { return runExecSpecDraft(execSpecDraftRun{ options: options, diff --git a/internal/notify/webhook_wire.go b/internal/notify/webhook_wire.go new file mode 100644 index 000000000..910b0fdb2 --- /dev/null +++ b/internal/notify/webhook_wire.go @@ -0,0 +1,40 @@ +package notify + +import "strings" + +// Webhook delivery is configured entirely from the environment. A webhook URL +// typically embeds a secret token, so sourcing it from the environment keeps it +// out of any on-disk config file. The sink is strictly opt-in: with +// EnvWebhookURL unset the wiring helper attaches nothing and the notifier +// behaves exactly as before. +const ( + // EnvWebhookURL holds the destination webhook/Slack URL. Empty disables it. + EnvWebhookURL = "ZERO_NOTIFY_WEBHOOK_URL" + // EnvWebhookSummary is an optional one-line run summary attached to every + // payload (for example "nightly audit run"). + EnvWebhookSummary = "ZERO_NOTIFY_WEBHOOK_SUMMARY" +) + +// MaybeAddWebhookSink attaches a webhook sink to n when a webhook URL is present +// in the environment, and is otherwise a no-op. env resolves an environment +// variable (pass os.Getenv); logf records one redacted line per failed delivery +// (pass nil to stay silent — for example a TUI that owns the screen). It is safe +// to call unconditionally: configuration alone decides whether the sink exists. +// +// The attached sink is still subject to the notifier's Mode/focus policy, so a +// webhook only delivers when notifications are enabled (for example +// `--notify both`), matching the rest of the notification surface. +func MaybeAddWebhookSink(n *Notifier, env func(string) string, logf func(format string, args ...any)) { + if n == nil || env == nil { + return + } + url := strings.TrimSpace(env(EnvWebhookURL)) + if url == "" { + return + } + n.AddSink(NewWebhookSink(WebhookConfig{ + URL: url, + Summary: strings.TrimSpace(env(EnvWebhookSummary)), + Logf: logf, + })) +} diff --git a/internal/notify/webhook_wire_test.go b/internal/notify/webhook_wire_test.go new file mode 100644 index 000000000..3263f1264 --- /dev/null +++ b/internal/notify/webhook_wire_test.go @@ -0,0 +1,71 @@ +package notify + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// envFunc builds a deterministic env resolver from a map for the wiring tests. +func envFunc(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +func TestMaybeAddWebhookSinkAttachesAndDelivers(t *testing.T) { + var hits int32 + var gotSummary string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + var payload webhookPayload + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload) + gotSummary = payload.Summary + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways}) + MaybeAddWebhookSink(n, envFunc(map[string]string{ + EnvWebhookURL: server.URL, + EnvWebhookSummary: "nightly audit", + }), nil) + + if got := len(n.sinks); got != 1 { + t.Fatalf("expected 1 sink attached, got %d", got) + } + + n.Notify(Completion, "Zero: ready") + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("webhook hit %d times, want 1", got) + } + if gotSummary != "nightly audit" { + t.Fatalf("summary = %q, want %q", gotSummary, "nightly audit") + } +} + +func TestMaybeAddWebhookSinkNoopWhenURLBlank(t *testing.T) { + n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways}) + + // Unset. + MaybeAddWebhookSink(n, envFunc(nil), nil) + // Set but blank / whitespace only. + MaybeAddWebhookSink(n, envFunc(map[string]string{EnvWebhookURL: " "}), nil) + + if got := len(n.sinks); got != 0 { + t.Fatalf("expected no sink attached, got %d", got) + } +} + +func TestMaybeAddWebhookSinkNilGuards(t *testing.T) { + // Must not panic on a nil notifier or nil env resolver. + MaybeAddWebhookSink(nil, envFunc(map[string]string{EnvWebhookURL: "https://example.test"}), nil) + + n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways}) + MaybeAddWebhookSink(n, nil, nil) + if got := len(n.sinks); got != 0 { + t.Fatalf("nil env must attach nothing, got %d sinks", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 409fd0b02..ca5112b7c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -365,6 +365,10 @@ func newModel(ctx context.Context, options Options) model { Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)), FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)), }) + // Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Delivery failures stay + // silent here: the TUI owns the alt-screen, so writing to stderr would + // corrupt the display. + notify.MaybeAddWebhookSink(notifier, os.Getenv, nil) notifier.SetFocused(true) m := model{