Repository navigation
feat(notifications): ntfy/Slack/Discord/generic webhook for the push events - #523
Conversation
…events Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rowser test Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Heads-up on merge order. I checked my open PRs against each other with trial merges. Every overlap is a textual conflict in a shared registry or list (adjacent insertions), not a behavioural interaction, but whichever lands first, the others need a rebase:
I'm not changing this PR for it. As the others merge I will rebase this one onto master, resolve the conflicts, re-run the full gate and push. If you have a preferred merge order, tell me and I will follow it. |
…d nits (#523 review) Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON). Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now persists the webhook group beside the settings PUT, the same way it already saves the model config (saveModelConfigFromSettings), but only when the group differs from what loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and scrolls to the group with the pasted URL still in the box, instead of a success toast. Send test now saves pending edits first, so it never tests the old URL while the box shows a new one. The row says so in one line. Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the private sendPushNotifications on a real (never started) WebServer with an EMPTY push store and webhook.json in the instance data dir, delivering through the real egress-guarded fetch to a local receiver: a permission prompt arrives with the host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is deduped, "response complete" is skipped under scope attention and sent under all, and a disabled config or a non-push event sends nothing. Verified it fails when the webhook call is moved below the "no subscriptions" return. Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus a table row, and a line in Settings-Reference; new section 10c in docs/security-architecture.md for the second outbound channel through the web-tab egress guard. Nits: - Orphaned JSDoc: the webhook schema moved below the push schemas, so PushSubscribeSchema has its comment back. - Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so the schema cannot accept a kind the store would coerce away. - describeError classifies egress refusals with isEgressBlockedError (the CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex; tests pin a deep cause chain and that matching words alone are not a refusal. - Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch row hints to pick a long random topic on public ntfy.sh. - Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a confirm) sends { url: "", enabled: false }. - Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module). Browser test extended: main Save persists a pending edit, a refused URL keeps the modal open with the URL, Send test saves a newly pasted URL first, Remove URL clears it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged and shipped in 1.34.0. Thanks @opticon454! Keeping the URL in its own 0600 file that the API never returns, and sending delivery through the web-tab egress guard, is exactly how a bearer-secret channel should work, and it is what made this easy to take. I applied the review items while landing (6d6e7da):
|
What
Opt-in webhook notifications. The same events that trigger Web Push (permission prompts, questions, errors, respawn blocked, crash loop, task complete, idle, response complete) can also be posted to ntfy, Slack, Discord, or any JSON URL, so a headless server can reach a phone with no browser tab open and no push subscription.
Settings → Notifications → "Webhook (ntfy, Slack, Discord)": enable, pick the service, paste the URL, choose "Needs attention" (skips the routine "response complete") or "Everything", Save, Send test. Off by default.
Design decisions worth a look
GET /api/settingsis readable by every logged-in user. So it is not a setting: it lives in~/.codeman/webhook.json(0600, tmp+rename, likecustom-model-hosts.json), is only written throughPUT /api/webhook, and is never returned (the API and UI show scheme + host only). The URL box is write-only; changing only the service or scope never needs the secret re-sent. All three routes are admin-only in multi-user mode (the channel receives every session's events, the same reach an admin's own Web Push has).webviewFetch): link-local and cloud-metadata targets are refused on the resolved address at connect time; save-time validation also rejects non-http(s),user:pass@, and those targets. Loopback and LAN stay allowed on purpose (a local ntfy is the feature). Redirects are not followed, the call times out after 5 s, and error text never contains the URL.allowed_mentions: { parse: [] }, Slack control characters (<!channel>,<@U…>,<url|text>) are escaped, and ntfy headers are RFC 2047-encoded so a title can't inject a header.sendPushNotifications(that is the headless case), never delays Web Push, dedupes the same event+session within 3 s, and caps in-flight requests at 5. The notification body text moved into a small static method so both channels share it; Web Push output is unchanged.Tests
test/webhook-notify.test.ts(39): URL validation, masking, scope, each service's request format, store (0600, tightens an existing 0644 file, coerces bad values), sender (status/redirect/timeout/network errors with no URL leak), notifier (dedupe, in-flight cap, test send), plus real delivery to a local HTTP server throughwebviewFetchand refusal of a metadata address / redirect-to-metadata.test/routes/webhook-routes.test.ts(17): masking, partial updates, clearing, 400s, strict schema, test route, multi-user gating.test/webhook-settings.browser.test.ts(6): real server + real Chromium + a local receiver: the UI saves, masks, sends a test that arrives with the ntfy headers, and shows a failing endpoint without exposing the URL.git-clonetests timed out once under load and pass alone, unrelated to this change).🤖 Generated with Claude Code