From 6d6e7da481db41a1918f64bca4590a4eff5ad462 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 4 Oct 2026 23:42:47 +0200 Subject: [PATCH] fix(notifications): main Save keeps webhook edits, glue test, docs and 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) --- CLAUDE.md | 2 +- docs/security-architecture.md | 10 ++ docs/wiki/Notifications-And-Approvals.md | 70 +++++++++++-- docs/wiki/Settings-Reference.md | 5 +- src/types/index.ts | 2 +- src/types/push.ts | 45 +++++++- src/web/public/index.html | 12 ++- src/web/public/settings-ui.js | 74 +++++++++++-- src/web/routes/webhook-routes.ts | 22 ++-- src/web/schemas.ts | 28 ++--- src/web/server.ts | 3 +- src/webhook-notify.ts | 43 +++----- test/webhook-notify.test.ts | 22 +++- test/webhook-push-glue.test.ts | 127 +++++++++++++++++++++++ test/webhook-settings.browser.test.ts | 46 ++++++++ 15 files changed, 427 insertions(+), 84 deletions(-) create mode 100644 test/webhook-push-glue.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 50aeedd3..34607abe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -431,7 +431,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index ab331aa4..d1153fbf 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -532,6 +532,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at ` --- +## 10c. Webhook notifications (outbound channel) + +Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md). + +- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed. +- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message. +- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice. + +--- + ## 11. Quick reference | Env / flag | Effect | diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index e7964f6b..fcc495a9 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -6,15 +6,16 @@ opening the session. ## The signals, cheapest first -| Surface | Reaches you | Default | -| ---------------------- | ------------------------------------------------- | ------- | -| Tab alert | While the dashboard is open | On | -| Browser title flash | Another tab in the same browser | On | -| Desktop notification | Another window on the same machine | Opt-in | -| Push notification | Anywhere, even with no tab open | Opt-in | -| Approvals Inbox | One queue across every session | Opt-in | -| Phone overview | Phone home screen, NEEDS YOU section | On | -| Away Digest | Afterwards, as a summary | Opt-in | +| Surface | Reaches you | Default | +| ------------------------------ | ------------------------------------------------ | ------- | +| Tab alert | While the dashboard is open | On | +| Browser title flash | Another tab in the same browser | On | +| Desktop notification | Another window on the same machine | Opt-in | +| Push notification | Anywhere, even with no tab open | Opt-in | +| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in | +| Approvals Inbox | One queue across every session | Opt-in | +| Phone overview | Phone home screen, NEEDS YOU section | On | +| Away Digest | Afterwards, as a summary | Opt-in | ## Tab alerts @@ -60,6 +61,51 @@ Setup: Once subscribed, a blocking prompt reaches your phone even from a locked screen. +## Webhooks: ntfy, Slack, Discord + +**Opt-in, off by default. One channel for the whole server.** + +Push needs a browser that subscribed once. A webhook needs nothing on the client side: the +server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any +URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser, +and for a team channel. + +It carries the same events as push: permission prompts, questions, idle sessions, session +errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete" +is included only when **Which events** is set to **Everything**; the default, **Needs +attention**, skips it. A session that is watching its own work stays quiet here too. + +Setup, in **App Settings → Notifications → Webhook**: + +1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and + Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency, + sessionId, sessionName, host, at }`. +2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**. +3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**. + Send test saves anything you changed first, so it always tests what is on screen. + +The status line under the group shows the last delivery: when it worked, or why it did not +(an HTTP status, a timeout, a refused connection). + +Behaviour worth knowing: + +- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and + anyone who knows an ntfy topic can read it. Codeman keeps it in its own file, + `~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and + never shows it again: once saved, the box is empty and the hint shows only the scheme and + host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server + (which also turns the channel off). +- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is + the only thing keeping strangers out. +- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine. + Link-local and cloud-metadata addresses are refused, both when you save and when the + message is sent, and redirects are not followed. +- **Repeats are folded.** The same event for the same session within three seconds is sent + once, so a flapping prompt cannot flood a channel. +- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change + the webhook, and it receives every user's session events (session names, tool names, error + text). Point it somewhere every user would be comfortable with. + ## The Approvals Inbox **Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal @@ -152,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in ## Recommended setup for unattended runs 1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access). -2. Push notifications subscribed, with Codeman installed to the home screen on iOS. +2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a + webhook to ntfy if no browser will ever be open. 3. Approvals Inbox on. 4. Auto-resume on usage limit on, for each session you leave running. See [Keeping Agents Running](Keeping-Agents-Running). @@ -162,7 +209,8 @@ from the lock screen. ## Gotchas -- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. +- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook + has no such requirement, since the server sends it. - **iOS needs the home screen install.** A Safari tab will never receive push. - **The bell is invisible at zero.** That is deliberate, not a broken setting. - **Approvals need real signals.** They are built on hook events, which Claude emits and diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 4e26616a..34b4d6d5 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -125,8 +125,9 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E ### Notifications -Master toggle, browser notifications, push subscription, audio alerts, and the idle -threshold that decides when a quiet session counts as needing you. See +Master toggle, browser notifications, push subscription, audio alerts, the idle +threshold that decides when a quiet session counts as needing you, and the server-wide +webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See [Notifications And Approvals](Notifications-And-Approvals). ### Voice diff --git a/src/types/index.ts b/src/types/index.ts index 0090639a..4ba3cca3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,7 +24,7 @@ * | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` | * | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE | * | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` | - * | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` | + * | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` | * | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` | * | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` | * diff --git a/src/types/push.ts b/src/types/push.ts index d541897e..3bd1c756 100644 --- a/src/types/push.ts +++ b/src/types/push.ts @@ -6,13 +6,17 @@ * Key exports: * - PushSubscriptionRecord — a registered push endpoint with per-event preferences * - VapidKeys — VAPID key pair (public + private) for Web Push authentication + * - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel + * (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push * * Persistence: * - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use) * - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404) + * - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret) * - * Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`, - * `POST /api/push/subscribe`. No dependencies on other domain modules. + * Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`, + * `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at + * `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules. */ /** A registered push subscription */ @@ -32,3 +36,40 @@ export interface VapidKeys { privateKey: string; generatedAt: number; } + +/** Services the webhook channel can format a message for. */ +export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const; +export type WebhookKind = (typeof WEBHOOK_KINDS)[number]; + +/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */ +export const WEBHOOK_SCOPES = ['attention', 'all'] as const; +export type WebhookScope = (typeof WEBHOOK_SCOPES)[number]; + +export type WebhookUrgency = 'critical' | 'warning' | 'info'; + +/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */ +export interface WebhookConfig { + enabled: boolean; + kind: WebhookKind; + url: string; + scope: WebhookScope; +} + +/** One delivery attempt. `error` never contains the URL. */ +export interface WebhookResult { + ok: boolean; + status?: number; + error?: string; + at: number; +} + +/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */ +export interface WebhookStatus { + enabled: boolean; + kind: WebhookKind; + scope: WebhookScope; + hasUrl: boolean; + /** Scheme + host only; the path and query are the secret. */ + urlMasked: string; + lastResult: WebhookResult | null; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 7acfc5f7..5d212e44 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2654,14 +2654,14 @@ - +
@@ -2692,10 +2692,14 @@
-
Save and test
+
+ Save and test + The main Settings Save saves this group too. Send test saves pending edits first. +
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8b315d45..b6b14098 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1216,6 +1216,12 @@ Object.assign(CodemanApp.prototype, { * Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a * settings-payload field: the URL is a secret, so it never round-trips through settings.json or * this page. The URL box is write-only; the status line shows scheme + host only. + * + * Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it + * never tests the old URL while the box shows a new one), and the modal's main Save, which calls + * saveWebhook() beside the settings PUT the same way it saves the model config + * (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so + * `_webhookPending()` can tell an edited group from an untouched one. */ _webhookSay(text, bad = false) { const out = document.getElementById('webhookResult'); @@ -1230,12 +1236,13 @@ Object.assign(CodemanApp.prototype, { if (!group) return; const res = await this._api('/api/webhook'); if (!res || !res.ok) { + this._webhookLoaded = null; group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route return; } let body = null; try { body = await res.json(); } catch { /* leave hidden */ } - if (!body || body.success === false) { group.style.display = 'none'; return; } + if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; } const d = body.data; group.style.display = ''; document.getElementById('webhookEnabled').checked = d.enabled === true; @@ -1245,6 +1252,14 @@ Object.assign(CodemanApp.prototype, { url.value = ''; url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic'; document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.'; + const clearBtn = document.getElementById('webhookClearBtn'); + if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none'; + // Read back from the controls, so a value the