Skip to content

feat(notifications): ntfy/Slack/Discord/generic webhook for the push events - #523

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feat/webhook-notifications
Oct 4, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feat/webhook-notifications

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

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

  • The URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), and GET /api/settings is readable by every logged-in user. So it is not a setting: it lives in ~/.codeman/webhook.json (0600, tmp+rename, like custom-model-hosts.json), is only written through PUT /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).
  • Delivery reuses the web-tab egress guard (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.
  • Agent text can't ping a channel: Discord gets 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.
  • Fire-and-forget: the send runs before the "no push subscriptions" early return in 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 through webviewFetch and 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.
  • Full CI gate: typecheck, lint, format, public assets, catalogue and 8564 tests pass (two git-clone tests timed out once under load and pass alone, unrelated to this change).

🤖 Generated with Claude Code

opticon454 and others added 2 commits October 2, 2026 22:06
…events

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rowser test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@opticon454

Copy link
Copy Markdown
Contributor Author

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.

@Ark0N
Ark0N merged commit 5724e0c into Ark0N:master Oct 4, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 4, 2026
…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>
Ark0N pushed a commit that referenced this pull request Oct 4, 2026
…#520, #521, #522, #523, #524, #530, #531)

One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 4, 2026

Copy link
Copy Markdown
Owner

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):

  • The App Settings Save button now saves webhook edits too. Only an edited group is sent, and a refused URL keeps the dialog open with a warning toast instead of silently dropping the change.
  • Send test saves pending edits first, so it never tests the old URL while the box shows a new one.
  • A "Remove URL" button clears a saved URL ({ url: "", enabled: false }).
  • test/webhook-push-glue.test.ts runs the real sendPushNotifications on a server with zero push subscriptions, the headless case this exists for.
  • Docs: webhook.json is in the CLAUDE.md State Files list, there is a Webhooks section in the Notifications wiki page, and docs/security-architecture.md covers the new outbound channel.
  • Nits: the schema reuses the shared kind/scope lists, egress refusals are recognised by error code rather than message text, and the config types moved to src/types/push.ts.

@opticon454
opticon454 deleted the feat/webhook-notifications branch October 6, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants