DeepSeek Harness Plugin

wingsky-1/dsh-plugin-hub#packages/dsh-notifier

Stars ★ 23 Downloads (30d) 3,389 Category Notifications & Integrations Added 2026-08-18 npm @wingsky-1/dsh-notifier

Task-event notification center: 6 event kinds (ask / approval / completion / subagent completion / error / turn end), dual channels (browser Notification + host system toast) plus Bark/Webhook push channels (ntfy, Gotify, self-hosted gateways); quiet hours with urgent exceptions, approval-timeout re-reminders, completion-storm aggregation, and notification text redaction.

Install

# from npm (prebuilt)

dsh plugin --profile web add @wingsky-1/dsh-notifier

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:wingsky-1/dsh-plugin-hub#path:/packages/dsh-notifier

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).

README

Notifications for approval / completion / error events: get alerted even when you are away from the browser.

简体中文 | English

Quick install

dsh plugin --profile web add @wingsky-1/dsh-notifier

After install / uninstall / update, restart dsh web once (bundle layers are only composed at startup) for changes to take effect.

Quick navigation

Before you start · Deployment and access · Quick start · Common configuration · Verification and troubleshooting · Detailed reference · Development and architecture

Before you start

Prerequisite: DeepSeek Harness installed and dsh web running normally (for running dsh without a global install, see "Without a global dsh install" below).

Deployment & how to access (important)

All plugin endpoints are protected by a loopback fence: only calls originating from the local loopback interface (127.0.0.1 / localhost) are accepted. Therefore, when you access http://<server IP>:3080 directly from a LAN browser, /api/dsh-notifier/* always returns 403 and the notification channels do not work — this is the expected behavior of the security guardrail, not a plugin fault; the page will show a guidance hint.

Pick one of the following access forms (both the settings card and the README surface a hint for each):

Form How to access Notes
Local desktop http://127.0.0.1:3080 Secure context: both browser notifications and system notifications work
LAN HTTPS (recommended) https://<server IP>:3443 (dsh-lan-proxy) Satisfies loopback check via proxy + secure context; on mobile, "Add to Home Screen" enables PWA-grade notifications
Tunnel After ssh -L 3080:127.0.0.1:3080 <server>, visit the local address Loopback + secure context, same effect as local

Verified (2026-08): https://<IP>:3443/api/dsh-notifier/health returns 200, SSE long connection (/api/dsh-notifier/events) delivers its first frame normally via 3443.

Security model

  • Notification text only contains metadata such as task title / tool name / request reason — never tool parameters (prevents sensitive info leakage)
  • Notification body and title are no longer masked (#733 convergence): the old sanitizeContent rule table (paths / PEM private keys / connection-string credentials / tokens / emails …) has been deleted — notifications, history writes (including suppressed entries) and delivery all carry the original text; the body is not truncated here, and length is capped by each delivery channel's display limit. Deployments that need "a given kind of text never appears in logs" must handle it at the event source
  • The only remaining credential masking is in the settings view: channel credentials in GET /config's user + effective and in PUT success responses (bark deviceKey, webhook token / password / headerValue) are always masked as ********; submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances); a mask with no stored value to restore (a new instance, or a cross-type mask left over from retyping) returns 400 — the write path and the dry-run share that one sentence, and a placeholder never reaches disk (CHANNEL_SECRET_FIELDS is the per-channel-type single source of truth)
  • Outbound error reasons no longer replace credential literals (measured risk, documented as-is): Bark 4xx response bodies echo the device key, and webhook non-2xx response bodies echo the credentials they received — failure reasons are only truncated (webhook response body 200 chars; status entry 300 chars), with no guarantee that credentials stay out of the error text. Those texts live in the reason's detail field (reasons are structured as of 0.2.4, see "Delivery reliability"), and go to server logs, the status file (status.json) and the notification history (history.jsonl), reaching the settings page via GET /status and GET /history; deployments sensitive to error-text exposure should act on the bullet above
  • Channel identity (id) is unique, cross-type included (#1016): two channels sharing one identity is not "a duplicate row in the UI" but credential destruction — the settings page submits channels as one whole group, the merge takes the first entry by bare id and lets the remaining keys inherit that entry's values, so the second entry's real credentials (bark deviceKey, webhook credentials) are overwritten by the first — one credential destroyed per save. A cross-type duplicate id is worse: mask restoration looks up by bare id, so a bark written with id: "browser" hits the built-in browser entry and restores its mask onto the bark's keys. The write path therefore returns an unconditional 400 for a duplicate identity (the message carries 1-based indices and says to delete one of them); the upgrade step no longer silently de-duplicates by identity (that would delete the second entry, credentials and all, at upgrade time); the delivery projection takes the first entry per identity — which keeps one notification from going out twice, at the price of the entry that loses no longer being delivered at all, and of the identity being claimed by whichever entry comes first in the array, not by whichever one works: a half-broken entry whose required delivery key is an empty string claims the identity first and pushes a later healthy entry with the same id out of the delivery pool (the half-broken entry itself is dropped from that pool anyway). No path hides or deletes your data: the view is forwarded verbatim, and deleting a card and saving goes through
  • Server-side internal errors still return fixed wording (root causes only go to server logs)
  • System notification failures are no longer silent: when a channel executed an action and it failed, the status row is written as failed and the per-channel detail appears in the notification history; when no command can be constructed at all, the outcome is skipped plus exactly one warn (as of 0.2.4 linux / darwin emit that log too — previously only the win32 branch did). A missing / non-executable native binary (ENOENT etc.) is caught by the error event and never bubbles up as an unhandled error that crashes the host process (see issue #1)
  • The two channels are delivered to different machines (don't confuse them):
    • Browser notifications are pushed to the browser client you are actually using (your Mac / phone both count), and pop a native notification via the browser's Notification API; they require permission and by default only pop when the page is hidden (the settings card can enable "also when visible"). No matter which machine dsh web runs on, as long as browser notifications are allowed you receive them on your own Mac.
    • System notifications (host toast) are popped on the desktop of the machine dsh web runs on: if dsh web runs on a Linux server (headless, no desktop session) or some other machine, the toast appears on that server, not your Mac — see /diagnostics for system-channel availability and the settings card for browser-channel availability. To also get the system toast on your Mac, run dsh web directly on your Mac (it then uses macOS osascript); macOS has no notify-send, and the system notification is already implemented via osascript (zero dependencies, nothing to install)
  • iOS difference: Safari's normal tabs have no Web Notifications API (only the "Add to Home Screen" PWA does); on iOS the available channels are "in-page banner + sound when the page is visible" and system notifications after HTTPS + A2HS
  • The capability self-check (as of 0.2.4) exposes a partial fingerprint of the host's software stack, and it is visible to the LAN once forwarded through dsh-lan-proxy: capabilities.host.sound.players on /diagnostics lists the matched player executable names (in fallback-chain order, all matches: paplay → pw-play → aplay → ffplay; always afplay on darwin), and the checked arrays reveal whether notify-send is installed; /health carries only a summary (verdict / unknownDimensions / popup.state / sound.state, no players/checked details). This is a deliberate trade-off — users cannot act on "the host cannot play sound" unless they can see it — but be aware of what it means combined with lan-proxy's existing posture (that plugin's README states that requests forwarded through it are considered trusted by design). Containment: executable names only, tone-file availability as a boolean, never absolute paths, and remediation params come solely from an internal data table (never passed through input), so no /etc/os-release content or raw command output can appear in a response
  • Probing has no side effects: the capability self-check issues only two read-only queries to org.freedesktop.DBus (NameHasOwner and ListActivatableNames) and never triggers service activation (no busctl status/list, no StartServiceByName); on darwin/win32 that child process is not even started
  • Temporary audio files (as of 0.2.4): on Linux, when the themed event sound is missing, self-play creates a 0700 per-instance directory under the system temp directory and writes a 0600 WAV opened with wx (wx refuses pre-existing paths and symlinks); the file is deleted as soon as playback ends — only this delivery's file, so a concurrent delivery is unaffected — while the directory is removed when the process exits / the plugin unloads. On a read-only /tmp nothing is staged: a sound-only delivery is recorded as skipped (reasonSystemToneUnwritable), while popup+sound still counts as ok with one warn (the popup already went out, so sound is best-effort)
  • On-disk permissions and write order (#1016): neither the plugin-private directory <DSH_HOME>/@wingsky-1/dsh-notifier/ nor the data files in it (config.json, history.jsonl, status.json, seq.json, version) drift with the umask — a directory this plugin creates lands 0700, and each file is written as a 0600 temporary name in the same directory that is then renamed over the target (config.json and status.json carry the bark device key, webhook credentials and failure reasons that may echo credentials). Pre-existing directories are tightened to mode & 0700: only the owner bits are kept, exactly as they are, so no permission bit is ever added. An older 0755/0777 directory is therefore pulled back to 0700, while a directory an operator deliberately made read-only becomes 0500 and stays read-only — the plugin never re-opens it for writing. The mask takes the special bits with it: this plugin goes through the syscall-level chmod (Node fs.chmod), which sets only the bits present in its mode argument and clears every bit absent from it (coreutils' chmod(1) deliberately keeps setuid/setgid on directories — that is not what is meant here), so setuid/setgid/sticky outside the owner triple are cleared too — measured on the very same formula, 2770, 1777, 4755 and 7777 all end up 0700, and the setgid of a team-shared 2770 is silently dropped (a hardening direction: those bits carry no practical meaning on a private data directory, but it is a registered behavior change). On a directory carrying a POSIX extended ACL the ACL mask is tightened to 0 as well, so named entries keep their own perm bits while their effective permissions drop to zero. Within a single process, asynchronous writes to the same path land in submission order (the queue is an in-process in-memory map: concurrent rename order was never guaranteed, and on the sequence file that showed up as the client silently dropping frames by seq; two processes sharing one DSH_HOME are not serialized against each other). The synchronous write writeTextAtomicSync is not on that chain — a synchronous function has no promise to hang it on, and it serves the exclusive path during an upgrade, so a second assembly on hot reload can run it concurrently with an in-flight async write; the serialization guarantee does not cover that. A failed write leaves no temporary file behind (unless the cleanup itself fails, in which case one stays: its name carries a random suffix, so the next write will not overwrite it, and it does not mask the real failure reason)
  • Residual trust surface of D-Bus notifications: on Linux the notification body is handed to the current owner of org.freedesktop.Notifications. Any process of the same UID that grabs the name first receives the content (cross-UID takeover does not work: the session bus is one socket per user). Deployments that need process isolation within the same user should evaluate the system channel themselves
  • Capability contract evolution: capabilities only gains keys, never loses them; the client ignores unknown groups and unknown verdict values (rendered as "unknown" rather than an error); with an older server that sends no capabilities, the settings page degrades gracefully. Upgrading the server therefore does not require upgrading the client
  • Browser notifications require a secure context (HTTPS or localhost); LAN HTTP access automatically routes through the fallback channel (banner / sound / title reminder)
  • Browser notification permission is requested within a gesture (via the "Request notification permission" button on the Settings → Plugins → dsh-notifier card)
  • Windows system notifications are implemented via a PowerShell WinRT script, with the command passed as a parameter array and title/body packed into a single base64 (UTF-8 JSON) payload argument (no shell concatenation surface, and immune to PS 5.1 command-line argument parsing ambiguities, see issue #238); the script idempotently registers the AppUserModelId DSH.dsh-notifier on startup (HKCU, no admin required) — an unregistered AUMID gets toasts silently dropped by Windows 10/11. The AUMID follows the Company.Product convention to avoid collisions in the public namespace (HKCU\SOFTWARE\Classes\AppUserModelId) where same-named apps overwrite each other's display names; a legacy DSH key registered by older versions is harmless leftover (just an empty registry entry, does not affect new toasts) and can be removed manually with Remove-Item -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\DSH" if desired

Quick start

Install plugins (add)

dsh plugin --profile web add @wingsky-1/dsh-notifier

After install / uninstall / update, restart dsh web once (bundle layers are only composed at startup) for changes to take effect.

Access and verify

Use an access form above, open Settings → Plugins → dsh-notifier, and click "Request notification permission". Send a test notification and check History for the per-channel result. Browser popups default to hidden pages; enable "also when visible" if needed. System toasts appear on the host, not the remote browser’s device.

curl -s http://127.0.0.1:3080/api/dsh-notifier/health

Common configuration

Edit the card under Settings → Plugins → dsh-notifier. Configuration is plugin-owned at <DSH_HOME>/@wingsky-1/dsh-notifier/config.json; storage, migration and unknown-key semantics are in Detailed reference.

Example values (defaults; channels / kindRoutes / allowKinds are new M2 keys):

{
  "notifyAsk": true,
  "notifyQuestion": true,
  "notifyTaskDone": true,
  "notifySubagentDone": false,
  "notifyTaskError": true,
  "notifyTurnEnd": false,
  "quietHours": { "enabled": false, "windows": [{ "start": "22:00", "end": "08:00" }], "allowKinds": [] },
  "historyMaxAgeDays": 0,
  "channels": [
    { "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false },
    { "type": "system", "id": "system", "enabled": true, "popup": true, "sound": true }
  ],
  "kindRoutes": {},
  "allowKinds": []
}

The browser and system notifications are the two built-in entries in channels: they live in the same array as bark / webhook instances and share the same rendering and decision logic; the only thing special about them is that they cannot be deleted (a write whose channels lacks a built-in entry returns 400). The 8 top-level channel keys of 0.2.3 (systemEnabled / browserEnabled / systemNotify / browserNotify / notifyWhenVisible / notifySound / browserSound / systemSound) are moved into these two entries and deleted during the upgrade — the migration is done in one version, with no second place where the old keys still read. Submitting them after the upgrade returns 400 (refresh the page if it was open before the upgrade).

Per-channel three switches (#640 / #641; folded into channel entries as of 0.2.4)

The browser and system notifications are two built-in channel entries in the channels array, with the same shape, rendering and decision logic as bark / webhook instances:

{ "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false }
{ "type": "system",  "id": "system",  "enabled": true, "popup": true, "sound": true }
Field Type Meaning
enabled boolean channel switch (send or not): the only delivery gate per channel; off = no delivery at all
popup boolean popup switch (pop or not): off with sound on = sound only
sound boolean | tone id sound (whether / which tone)
whenVisible boolean whether to also pop while the page is visible (browser channel only; sent with the frame and executed by the page)

What gets sent is up to the channel: the decision pipeline judges enabled only, per channel; popup and sound are handed to the channel as-is, and it decides whether this one pops, sounds, sounds without popping, or sends nothing at all. So a channel with "popup off + sound off" still receives the delivery — the outlet determines there is nothing to send this time, and history records a skipped entry (neither disguising it as a successful delivery, nor judging the shape inside the pipeline on the outlet's behalf).

The old top-level keys are moved away and deleted during the upgrade: the 0.2.3 keys systemEnabled / browserEnabled / systemNotify / browserNotify / notifyWhenVisible / notifySound / browserSound / systemSound are moved into the two built-in entries by the 0.2.4 upgrade chain and then deleted from the configuration file — the channel shape is expressed in the entries alone. Submitting those keys after the upgrade returns 400 (with a hint to refresh) instead of silently doing nothing.

Values: false = muted (a popup may still show, without sound); true = follow the system default; a tone id = an explicit built-in tone (ding / bell / chime / pop — the 4 tones have consistent meaning across platforms; pick one in the "Tone" dropdown of each channel card and hit Preview: the preview is synthesized locally with Web Audio as a listening reference — the real system sound follows the platform and system settings).

Delivery matrix (with the channel switch off nothing is delivered, regardless of popup and sound; with it on, popup × sound decide the shape):

Enabled Popup Sound Behavior
Off any any no delivery at all (sending depends on the channel switch only)
On On false Show notification, silent
On On true Show notification, OS-default sound
On On tone id Show notification; the app self-plays the tone (system notification silenced to avoid double sound)
On Off true/tone id Sound only: no popup, self-play only (page alive / host self-play)
On Off false Enters the pool but sends nothing: history records skipped, and the settings card says so
  • notifySound (old global key) is migrated by the upgrade: its value is spread onto the two built-in entries' sound by the 0.2.4 upgrade (an outlet key — browserSound / systemSound — wins when present), and the old key is then deleted. Users who had muted the old sound therefore stay muted after upgrade — no surprise sound. From 0.2.4 the settings UI only writes the entries; there is no global sound switch any more.
  • Browser sound unlock prerequisite: browser true/tone self-play needs an unlocked page audio context — browser autoplay policy requires one user gesture (opening the notification center / any sound-row interaction unlocks the AudioContext). A purely background page that was never interacted with may stay silent (notifications still pop, just no sound) — a browser policy constraint, not a plugin defect.
  • Linux true special case (#640 fix): Linux desktop daemons differ widely in sound-hint support (GNOME silent by default / KDE only since 2025 / Xfce needs libcanberra), so the system channel's sound: true means "self-play the default event sound": the host walks the fallback chain paplay → pw-play → aplay → ffplay and stops at the first success (no double sound), instead of relying on the daemon. When the freedesktop event sound is missing it now uses a tone synthesized at runtime (as of 0.2.4), so a host without the sound theme no longer stays silent. Finding only sound-server players is a known degradation: paplay/pw-play always fail on a host without a sound server, so the capability face reports degraded with host-only-sound-server-players — installing a player that talks to ALSA directly fixes it (alsa-utils provides aplay, ffmpeg provides ffplay; on dnf-family hosts ffmpeg comes from RPM Fusion). A host with no audio device still stays silent. Behavior change for existing Linux installs: system notifications used to be silent (notify-send had no sound hint); after this upgrade, sound-on self-plays the event sound.
  • Tone × platform mapping (approximate, best-effort):
Tone Browser (Web Audio) macOS Linux (event sound, synthesized when missing) Windows
ding double short high Glass (NSSound) message-new-instant.oga C:\Windows\Media\Windows Ding.wav
bell single mid-high Tink bell.oga Windows Chimes.wav
chime three-note ascent Sosumi complete.oga Windows Chord.wav
pop short low Pop message.oga Windows Balloon.wav
true (system) OS default (not silent) Glass (kept) default event self-play (synthesized when missing) toast default system sound; near-default wav for sound-only

macOS playback is subject to the system "Allow notification sounds" setting; Windows tones are played by the host SoundPlayer from built-in wav files (allow-listed paths, silent when missing); Linux event files are the oga files guaranteed to exist in the sound-theme-freedesktop base package under /usr/share/sounds/freedesktop/stereo/ (multi-path probing, falling back to a tone synthesized at runtime when missing). Self-play always uses argument-array spawning (no shell concatenation) and allow-listed file paths.

  • The host platform is exposed via the platform field of /api/dsh-notifier/health (the system card shows a platform hint from it — the browser OS and the host OS can differ; don't confuse them).
  • System-channel availability lives in /diagnostics (probed on the host, with remediation advice); browser-channel availability is computed in the settings card (client-side, so it differs per device).

Event routing and confirmed kinds

kindRoutes: a sparse kind → channelId[] routing map (e.g. { "error": ["browser", "system", "bark:phone"] }); kinds without an entry broadcast to every enabled channel; the events area of the settings page edits it both ways (sharing one copy of the configuration with the channel cards). allowKinds: the list of confirmed dynamic kinds (notification types registered by other plugins are persisted here once you confirm them).

Bark push channel (M2, issue #366)

Configure via the "Notification center → Delivery channels → Add Bark push" tab (or edit the configuration JSON above directly). Per-instance fields: id (auto-generated then locked), name (display name), baseUrl (Bark server address, http/https), deviceKey (found in the Bark app; always masked as ******** in responses, submitting the mask = keep the original value), enabled (default false — outbound authorization must be granted explicitly).

Optional parameters (all omitted = not sent; unknown string/number keys pass through verbatim since #1016 S2 a channel entry's unknown keys are no longer passed through — they never reach the effective config or the push body and are rejected with 400 on submit; device_key / device_keys / ciphertext are reserved keys, with wording that differs from the "never legal" keys):

Field Notes
sound Ringtone name (Bark Sounds list)
group Group (notifications in the same group collapse on the phone)
icon Icon URL (must be reachable from the phone's network, not from the server; SVG needs iOS 17+; empty uses the Bark default)
url URL opened when the notification is tapped
badge App badge number
level Instance-level urgency override; when omitted, mapped automatically from event severity: failure→timeSensitive, warning/success→active, info→passive
levels Per-event (kind) urgency sparse mapping (below)

levels (kind→level sparse mapping matrix): sets Bark urgency for a concrete event type, taking precedence over the instance-level level and the severity auto-mapping; unconfigured types use the default. Suited to per-event differentiation such as "questions must ring, subagent completions stay quiet":

{ "id": "phone", "type": "bark", "baseUrl": "https://api.day.app", "deviceKey": "…",
  "enabled": true, "levels": { "question": "timeSensitive", "subagent-done": "passive" } }
  • Keys are event kinds (the built-ins ask/question/done/subagent-done/error/turn-end/test or dynamic kinds; any string); values are limited to active / timeSensitive / passive / critical; at most 64 entries (the write side rejects an over-limit submission; an over-limit value already on disk is not re-judged by an unrelated save), each key at most 64 chars.
  • Full precedence: levels[kind] > level > severity mapping > not carried.
  • Note: critical requires special Apple authorization (regular apps cannot request it); without it Bark may downgrade or reject the request.
  • Orthogonal to kindRoutes (kind→channelId[] routing): routing decides "which channels receive it", levels decides "how loudly this instance rings".

Delivery reliability: 10 s hard timeout, network errors / 5xx retried ×2 (4xx not retried), at most 2 in-flight deliveries per instance (built-in channels are unlimited); success requires both HTTP 2xx and a response body with code===200. Terminal delivery states are persisted to this plugin's status file (see "Storage layout" below), and the settings-page channel-card status row is refreshed on card load and after sending a test via GET /api/dsh-notifier/status (no polling, the D20 stance).

  • Bark channel credentials & outbound security (M2):
    • The device key never lands in the URL: pushes go to POST {baseUrl}/push with a JSON body (the device_key field) — reverse-proxy access logs record URLs and headers by default, never bodies
    • A single masking exit: deviceKey in GET /config's user+effective and in PUT success responses is always masked as ********; submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances)
    • No credential replacement at the error exit (measured): Bark 4xx response bodies echo the key verbatim — failure reasons are truncated as-is and go to the logger and to status.json; the literal device-key replacement is gone, and so is the sent event as an exit (see "Security model")
    • SSRF posture: baseUrl is limited to the http/https scheme and credential URLs (user:pass@host) are rejected. These criteria (plus URL parse failure) are checked one by one by a pre-egress gate (deliver/url-gate.ts) before fetch: a non-conforming target is never sent, and its reason lands in the failure reason's detail (retryable=false, no re-send — a URL is a configuration fact, re-sending only hits the same wrong address three times). No domain allowlist — pointing baseUrl at an intranet self-hosted bark-server is a legitimate case
    • /push is joined onto the pathname via the URL API (#1016 P0): this used to be the string concat baseUrl + "/push", where a base carrying a query dropped /push into the query string (the request actually hit the truncated path) and a base with a trailing slash produced a //push double slash; now /push always lands on the pathname and the query is preserved as-is after it
    • Declared residual risk (security debt, the #1016 relaxation): this bullet used to promise "query/hash are dropped" to users. The current direction change gives up machine enforcement — credentials in the query (?token=, ?api_key=) travel in the URL and land in the peer's access logs, and there is now no machine check at all, only this README's documented convention that credentials go in request headers only, never in the URL. A diagnostic warning for credential-shaped queries needs a new presentation surface (config batch scope), and blocking them on the write face by keyword would also hit a legitimate ?api_key= on a user's self-hosted gateway — both are registered as follow-ups
    • Other known residual risk: a caller able to reach dsh web from the LAN (through a lan-proxy reverse proxy it can cross the loopback fence, see the deployment doc) can use /test to trigger one outbound POST to baseUrl (semi-blind: the response error summary echoes only a truncated raw excerpt). Deployments sensitive to that risk can turn the plugin's enabled off or adopt a dedicated-port setup (a later version)

Webhook push channel (#508)

Configure via the "Notification center → Delivery channels → Add Webhook push" tab (or edit the configuration JSON directly). Purpose: receive notifications on Android via ntfy / Gotify / a self-hosted push gateway, complementing Bark (iOS); each delivery POSTs a JSON body to url.

Per-instance fields (type fixed to "webhook"):

Field Notes
id Instance id (2-32 chars, lowercase letters/digits/hyphens; locked after creation; the alignment key for kindRoutes and mask backfill)
name Display name (falls back to the id)
url Target URL (the write face only requires a non-empty string; the pre-egress gate rejects non-http(s) and URLs carrying embedded credentials, and preserves the query as-is)
enabled Whether enabled (default false — outbound authorization must be granted explicitly)
auth Authentication: none (default) / bearer / basic / header
token Bearer token (secret: always masked ******** in responses)
username Basic auth username (not a secret)
password Basic auth password (secret: always masked)
headerName / headerValue Custom-header authentication (see constraints below).
preset Preset: ntfy (default) / gotify / custom (self-hosted gateway); selects the {{priority}} mapping and the default template
template JSON body template (≤8192 chars, the write side rejects an over-limit submission; empty = preset default template)
timeoutSec Delivery timeout in seconds (1-60, default 10; authoritatively clamped server-side)

Custom-header constraints: Custom-header auth (headerValue is a secret: masked); header names are limited to letters/digits/hyphens (≤64 chars) and forbid content-type / content-length / host / cookie / authorization

Presets and the channel-aware {{priority}} mapping (selected by preset; {{severity}} is always the raw severity):

preset info success warning failure
ntfy default low high urgent
gotify 3 3 7 9

custom does no mapping — {{priority}} passes the severity through verbatim for the gateway to handle.

Template placeholder list: {{title}}, {{message}}, {{kind}}, {{severity}}, {{priority}} (mapping above), {{source}} (renders as an empty string, reserved), and {{ts}} (rounded epoch milliseconds, emitted as a bare number — the only placeholder allowed unquoted in the template).

Rendering semantics (JSON-aware, two steps): {{ts}} is substituted as a numeric literal first → the template is parsed with JSON.parse → placeholders are replaced in string values only while walking the parsed tree → re-serialized with JSON.stringify. Substitution happens inside already-parsed strings and is uniformly escaped on re-serialization, so notification content containing quotes or "}} cannot break out of a string to inject extra fields. A template that is not valid JSON fails that channel's delivery and is recorded (never silently downgraded to plain text; other channels are unaffected). The ntfy preset default template contains "topic": "<topic>" — replace it with your topic name before delivering.

Delivery reliability: timeout 1-60 s (default 10); failures are never retried automatically — 4xx / 5xx / network errors / render failures all end as a terminal failure recorded in the status file and the notification history (the raw host text is truncated as-is into the reason's detail, see "Security model"); re-send via "Send test notification" to verify. Reference channels in kindRoutes as webhook:<id> (same type:id shape as bark:<id>).

Example instance (stored alongside Bark instances in the channels array, ids unique across types):

{ "id": "droid", "type": "webhook", "url": "https://ntfy.sh/mytopic",
  "enabled": true, "auth": "bearer", "token": "…", "preset": "ntfy", "timeoutSec": 10 }
  • Webhook channel credentials & outbound security (#508):
    • Disabled by default: enabled defaults to false — outbound authorization must be granted explicitly (same posture as Bark)

    • Credentials never land in the URL: credentials travel only in request headers (bearer → Authorization: Bearer, basic → Authorization: Basic (base64), header → custom header name + value); reverse-proxy access logs (URL + header names) never see them

    • Credential masking funneled via CHANNEL_SECRET_FIELDS: the masked-field list is a per-channel-type single source of truth (bark → deviceKey, webhook → token/password/headerValue); GET /config (user + effective) and PUT success responses always mask ********, submitting the full mask = keep the original value (backfilled aligned by instance id so a reordering never swaps credentials between instances). The mask means "unmodified" only on credential fields: on any other key it is an ordinary string (a channel name typed as eight asterisks is written through as-is), while a mask sitting on another type's credential field name (a cross-type leftover from retyping) returns 400; so does a mask on this type's credential field with no stored value to restore (a new instance, a renamed id). Those are two distinct hints (RESIDUAL_MASK_HINT / NEW_CHANNEL_MASK_HINT), and the write path and the dry-run give the verbatim same hint for the same situation (both defined in src/server/config/impl/service/merge.ts)

    • Reserved keys block config bypass (WEBHOOK_RESERVED_KEYS): credential alias keys such as auth_token / access_token / bearer_token / api_key / apikey / client_secret / secret / password_hash are rejected with 400 on write (never materialized on read; whatever is already on disk is preserved) — legitimate credentials can only enter via the known secret fields (masked end to end)

    • JSON injection protection: the template renders JSON-aware in two steps (value-level substitution + uniform re-serialization escaping); notification content cannot break out of a string to inject extra JSON fields

    • Outbound errors do not replace credentials: same as Bark — non-2xx response bodies are truncated to 200 chars and enter the reason's detail as-is, with no credential-literal replacement and no rule table (see "Security model")

    • URL SSRF posture (same gate as Bark): http/https schemes only, credential URLs (user:pass@host) rejected — checked one by one before egress, which refuses to send a non-conforming target (retryable=false, reason in detail); no domain allowlist — an intranet self-hosted gateway is a legitimate use case; custom header names forbid end-to-end headers (content-type/content-length/host/cookie/authorization) against request smuggling / JSON body corruption

    • The url is used verbatim, never concatenated (unlike Bark): fetch(target.url) sends it as written, so an address carrying a query (https://gateway/hook?tenant=x) is legitimate and works. The same declared residual risk as above applies: credentials in the query are no longer machine-blocked (the #1016 relaxation)

    • No retry on failure: a failed delivery is terminal (4xx/5xx/network/render) — no retry-driven outbound amplification

    • Webhook is an additive channel type: the semantics and compatibility commitments of existing channels and notification outputs (SSE frames / system notifications / history jsonl) are unchanged

Verification and troubleshooting

Check health and host capabilities from loopback; inspect the History tab for per-notification outcomes rather than relying only on the channel status row. The first capability probe has an 8s total budget; later reads use the shared cache.

curl -s http://127.0.0.1:3080/api/dsh-notifier/health
curl -s http://127.0.0.1:3080/api/dsh-notifier/diagnostics

Delivery outcomes and reasons

Three terminal states with different meanings (0.2.4): ok = a channel really executed an action and it succeeded; failed = an action was executed and it failed (there is failure evidence, so the status row is written); skipped = there was no executable action at all (popup and sound are both off, or this host cannot produce the command). The system channel treats popup and sound as two independent actions: once the popup has gone out, a sound failure is best-effort and does not change the terminal state (sound is no longer the only action); a popup failure still flips it. skipped does not write the status row — the channel did nothing, so there is no "latest delivery outcome" to speak of, and writing success would claim success on its behalf. A green status row therefore does not mean this particular notification arrived: every entry in the History tab carries its per-channel delivery detail (which channel, which outcome, which reason), and that is the only per-notification visible surface.

Delivery reasons are structured (0.2.4): { code, params?, detail? } — code is rendered into the current language by the client dictionary, and detail holds raw host output (HTTP response body, stderr tail, JSON.parse error) and is never the primary text; the UI folds it away behind a "Raw host output" label. The upgrade folds pre-0.2.4 prose reasons in status.json / history.jsonl into code: "reasonLegacy" with the original sentence in detail (idempotent; the read side is tolerant as well, so a hand-edited file cannot make the UI show undefined).

Detailed reference

Configuration storage and migration

Configuration is owned by the plugin itself and lives in config.json inside its package-private storage directory (<DSH_HOME>/@wingsky-1/dsh-notifier/config.json, ~/.dsh by default), read and written through the plugin card under Settings → Plugins → dsh-notifier or via GET/PUT /api/dsh-notifier/config. During upgrade, legacy locations are read once at assembly time and migrated into the current shape. The only formal historical sources are the dsh-notifier sections of <DSH_HOME>/settings.yaml.imported and <DSH_HOME>/settings.yaml: the imported file is merged first, then the current file overrides it. If either formal file exists but cannot be read, parsed, validated, or serialized, the upgrade fails immediately instead of falling through to a guessed location. Only when the formal sources yield no data does migration fall back, in order, to the registered section returned by settings describe() and then to the older self-maintained dsh-notifier.json (including the .migrated.bak left by an earlier migration). What is read is merged into the current config.json (legacy values override the file, the same precedence the old write path used), and the 8 top-level channel keys are then moved into the two built-in entries of channels and deleted (see "Per-channel three switches"). After that config.json is the only read/write path.

Unknown-key semantics (#1016 S2: wide reads, strict writes): on the read side keys the plugin does not recognize are still preserved verbatim and kept visible (existing values are never lost or migrated); on the write side they are always rejected with 400 — the same rule for top-level keys and for keys inside a channel entry.

  • Reading: GET /api/dsh-notifier/config returns unknown keys verbatim in user (the raw user layer), keeping future-version / third-party keys visible. effective is the view projection (#1016 S3: known-key subset + verbatim + masking, no default filling): it takes only the 11 known top-level keys (an unknown top-level key would trip the write path's 400, so it never enters the view), while unknown keys inside a channels entry are passed through as-is — the client hands them back unchanged, the write path reads that as "brought back verbatim" and does not re-judge their domain, so they are not rejected. The delivery layer (readConfig()) is a different channel: it materializes field by field over the known keys, and an unknown key never reaches it.
  • Writing: PUT /api/dsh-notifier/config is an incremental patch. A patch carrying an unknown key (e.g. {"futureKey":1}) returns 400 "futureKey is not a known config key — delete it or check the spelling"; only an empty patch {} (or a patch with nothing writable after filtering, e.g. only assembly keys) returns 400 "need at least one config key". Unknown keys already on disk are unaffected: they are not in the patch, channels keeps them field by field, and saving other known keys neither drops them nor gets rejected because of them. To clear one, delete it manually in config.json (it is also removed automatically when upgrading to 0.2.9 or later — see "Upgrade path").
  • Why no longer pass-through: the pass-through surface required the read side to fold a channel entry's unknown keys into an extras sub-object and hand it back verbatim, while the write side only accepted string/number values — so the extras object itself collided with that rule and the whole configuration became unsaveable (#1016 defect B). Narrowing to "keys this version knows" closes that self-collision while still keeping what is already on disk.
  • Upgrade path: since 0.2.9, unknown keys in the configuration file are cleaned up at upgrade time (top-level keys and keys inside channel entries alike; every category is listed in the 0.2.9 migration entry under the configuration-format appendix) — they could never be submitted anyway (the write path always returns 400), and keeping them only makes the settings page show a field that cannot be changed. The boundary is "a value that cannot possibly be legal in this version": anything the user has already expressed stays byte for byte (a full levels map, an 8192-character template, credentials left empty, a half-broken entry whose required key is empty or absent) — the upgrade deletes keys, it never rewrites values.
  • Legacy migration: unknown keys in the old configuration (the 0.2.3 settings namespace and the older self-maintained json) are preserved when read — written when missing from the user layer, never overwriting existing ones; a legacy file containing only unknown keys is no longer treated as "no valid keys". That only guarantees they are not lost on the way into config.json; once the version marker reaches 0.2.9 the shape cleanup drops keys this version does not know (see "Upgrade path").
  • Boundary exceptions:
    • patch must be an object: non-object shapes (arrays, null, numbers, etc.) always return 400 — arrays are never passed through as numeric-index dirty keys.
    • Prototype-chain keys (__proto__, constructor, prototype, which JSON text can inject as own keys) are always stripped on the write path — never judged, never written. Stripping already blocks the prototype rewrite, so they are not reported as "unknown key" 400s.
    • Composition-layer assembly keys (configFile / toastScript / historyFile / statusFile / enabled) are cordis composition/startup parameters and never enter the user layer — PUT and migration drop same-named keys; entry composition goes through the whitelist filter.
    • Reserved keys are rejected with 400 too — device_key / device_keys / ciphertext inside a Bark channel instance, and WEBHOOK_RESERVED_KEYS (auth_token / access_token / bearer_token / api_key / apikey / client_secret / secret / password_hash) inside a webhook channel instance — but the wording differs from the "never legal" keys: the former says "is a reserved key: credentials can only go through the known fields", the latter says "is not a known key: delete it or check the spelling". The two need different troubleshooting.

Consequence: after an upgrade, if the settings page does not show a field that still exists in config.json, that is the intended preserve behavior — saving other known settings will not lose it.

Storage layout (#733 convergence): configuration, notification history, channel status, the SSE seq counter and the storage version marker all live under DSH_HOME/@wingsky-1/dsh-notifier/ — config.json / history.jsonl / status.json / seq.json / version (version is the upgrade chain's scale). Legacy locations are read once at startup: the DSH_HOME-root dsh-notifier-history.jsonl / dsh-notifier-status.json / notifier-seq.json are renamed to .migrated.bak after being moved; the two generations of configuration (the 0.2.3 settings namespace and the older dsh-notifier.json) are read without being renamed. All paths respect DSH_HOME (#510): they resolve to ~/.dsh when the variable is unset and follow the isolated home when set — isolated environments (multi-instance / test sandboxes / dsh-verify-isolated) never touch the real ~/.dsh.

SSE lifecycle and retired connection cap

The SSE connection table (managed by shared/sse-hub since #515) no longer has a connection cap: cap eviction has been removed entirely — it was a stopgap for the "connection leak" era. Two complementary reclamation paths remain: stalled reclamation (writes rejected for over 90 s → disconnect) and maxAge rotation (alive for over 120 min with no business frames → actively disconnected; clients auto-reconnect and replay with since, so it is transparent). Reclamation-path counters are e

…

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.