diff --git a/.changeset/webhook-notifications.md b/.changeset/webhook-notifications.md
new file mode 100644
index 000000000..ae9ffab19
--- /dev/null
+++ b/.changeset/webhook-notifications.md
@@ -0,0 +1,5 @@
+---
+"aicodeman": minor
+---
+
+Webhook notifications. Settings → Notifications → "Webhook" posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it is stored in its own 0600 file, never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery refuses link-local and cloud-metadata targets, does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
diff --git a/config/test-suites.ts b/config/test-suites.ts
index 9f3ed295a..407a7b1b5 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
+ 'test/webhook-settings.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
];
diff --git a/docs/api-reference.md b/docs/api-reference.md
index c0f7235dd..f71627676 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -713,6 +713,18 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
+## Webhook notifications
+
+Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
+
+| Method | Path | Body | Notes |
+| ------ | -------------------- | -------------------------------------------- | ----- |
+| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
+| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
+| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
+
+Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
+
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 40d19750a..56b95c8d7 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2601,6 +2601,53 @@
Notifications
+
+
+
Webhook (ntfy, Slack, Discord)
server
+
+
+
+ Send alerts to a webhook
+ Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved.
+
+
+
+
+
Service
+
+
+
+
+ Webhook URL
+ Nothing saved yet.
+
+
+
+
+
+ Which events
+ "Needs attention" skips the routine "response complete" message.
+
+
+
+
+
Save and test
+
+
+
+
+
+
+
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 6c5208e80..f0b3b6683 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -416,6 +416,7 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
+ this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -1114,6 +1115,88 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
+ /**
+ * 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.
+ */
+ _webhookSay(text, bad = false) {
+ const out = document.getElementById('webhookResult');
+ if (!out) return;
+ out.textContent = text;
+ out.style.display = text ? 'block' : 'none';
+ out.style.color = bad ? 'var(--danger, #e5534b)' : '';
+ },
+
+ async loadWebhook() {
+ const group = document.getElementById('webhookGroup');
+ if (!group) return;
+ const res = await this._api('/api/webhook');
+ if (!res || !res.ok) {
+ 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; }
+ const d = body.data;
+ group.style.display = '';
+ document.getElementById('webhookEnabled').checked = d.enabled === true;
+ document.getElementById('webhookKind').value = d.kind;
+ document.getElementById('webhookScope').value = d.scope;
+ const url = document.getElementById('webhookUrl');
+ 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.';
+ if (d.lastResult) {
+ const when = new Date(d.lastResult.at).toLocaleString();
+ this._webhookSay(
+ d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
+ !d.lastResult.ok
+ );
+ } else {
+ this._webhookSay('');
+ }
+ },
+
+ async saveWebhook() {
+ const payload = {
+ enabled: document.getElementById('webhookEnabled').checked,
+ kind: document.getElementById('webhookKind').value,
+ scope: document.getElementById('webhookScope').value,
+ };
+ const url = document.getElementById('webhookUrl').value.trim();
+ if (url) payload.url = url; // blank = keep the saved one
+ const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
+ let body = null;
+ try { body = res ? await res.json() : null; } catch { /* fall through */ }
+ if (!res || !res.ok || !body || body.success === false) {
+ this._webhookSay(body?.error || 'Could not save the webhook.', true);
+ return;
+ }
+ await this.loadWebhook();
+ this._webhookSay('Saved.');
+ },
+
+ async testWebhook() {
+ const btn = document.getElementById('webhookTestBtn');
+ if (btn) btn.disabled = true;
+ this._webhookSay('Sending…');
+ try {
+ const res = await this._apiPost('/api/webhook/test', {});
+ let body = null;
+ try { body = res ? await res.json() : null; } catch { /* fall through */ }
+ if (!res || !res.ok || !body || body.success === false) {
+ this._webhookSay(body?.error || 'Could not send the test.', true);
+ return;
+ }
+ const r = body.data;
+ this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+ },
+
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts
index 2a7e41b59..107a566a4 100644
--- a/src/web/routes/index.ts
+++ b/src/web/routes/index.ts
@@ -28,6 +28,7 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
+export { registerWebhookRoutes } from './webhook-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
diff --git a/src/web/routes/webhook-routes.ts b/src/web/routes/webhook-routes.ts
new file mode 100644
index 000000000..e1bd33cc1
--- /dev/null
+++ b/src/web/routes/webhook-routes.ts
@@ -0,0 +1,115 @@
+/**
+ * @fileoverview Webhook notification settings (src/webhook-notify.ts).
+ *
+ * GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
+ * PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
+ * POST /api/webhook/test — send one test message with the saved config
+ *
+ * The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
+ * stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
+ * the channel receives every session's events, the same reach an admin's own Web Push has.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
+import { isAdmin, parseBody } from '../route-helpers.js';
+import { isMultiUserMode } from '../../config/multiuser.js';
+import { WebhookUpdateSchema } from '../schemas.js';
+import {
+ maskWebhookUrl,
+ readWebhookConfig,
+ webhookUrlProblem,
+ writeWebhookConfig,
+ type WebhookKind,
+ type WebhookNotifier,
+ type WebhookResult,
+ type WebhookScope,
+} from '../../webhook-notify.js';
+
+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;
+}
+
+export interface WebhookRouteDeps {
+ notifier: WebhookNotifier;
+ configDir: string;
+ /** The instance's window title, so a test message says which machine sent it. */
+ hostTitle: () => string;
+}
+
+export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
+ const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse | null => {
+ if (isMultiUserMode() && !isAdmin(req)) {
+ reply.code(403);
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
+ }
+ return null;
+ };
+
+ const status = async (): Promise => {
+ const cfg = await readWebhookConfig(deps.configDir);
+ return {
+ enabled: cfg.enabled,
+ kind: cfg.kind,
+ scope: cfg.scope,
+ hasUrl: cfg.url !== '',
+ urlMasked: maskWebhookUrl(cfg.url),
+ lastResult: deps.notifier.lastResult,
+ };
+ };
+
+ app.get('/api/webhook', async (req, reply): Promise> => {
+ const no = denied(req, reply);
+ if (no) return no;
+ return { success: true, data: await status() };
+ });
+
+ app.put('/api/webhook', async (req, reply): Promise> => {
+ const no = denied(req, reply);
+ if (no) return no;
+ const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
+ const current = await readWebhookConfig(deps.configDir);
+ const next = {
+ enabled: patch.enabled ?? current.enabled,
+ kind: patch.kind ?? current.kind,
+ scope: patch.scope ?? current.scope,
+ url: patch.url !== undefined ? patch.url.trim() : current.url,
+ };
+ if (next.url) {
+ const problem = webhookUrlProblem(next.url);
+ if (problem) {
+ reply.code(400);
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
+ }
+ }
+ if (next.enabled && !next.url) {
+ reply.code(400);
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
+ }
+ try {
+ await writeWebhookConfig(deps.configDir, next);
+ } catch (err) {
+ reply.code(500);
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
+ }
+ return { success: true, data: await status() };
+ });
+
+ app.post('/api/webhook/test', async (req, reply): Promise> => {
+ const no = denied(req, reply);
+ if (no) return no;
+ const cfg = await readWebhookConfig(deps.configDir);
+ if (!cfg.url) {
+ reply.code(400);
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
+ }
+ // 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
+ return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
+ });
+}
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index c12a83a9a..ff5f9332f 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -1827,6 +1827,19 @@ export const RespawnEnableSchema = z.object({
// ========== Web Push ==========
/** POST /api/push/subscribe */
+/**
+ * PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
+ * kind or scope never needs the secret re-sent, and an empty string clears it.
+ */
+export const WebhookUpdateSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ kind: z.enum(['ntfy', 'slack', 'discord', 'generic']).optional(),
+ scope: z.enum(['attention', 'all']).optional(),
+ url: z.string().max(2048).optional(),
+ })
+ .strict();
+
export const PushSubscribeSchema = z.object({
endpoint: z
.string()
diff --git a/src/web/server.ts b/src/web/server.ts
index d70577e67..74ac9821b 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -44,6 +44,8 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
+import { WebhookNotifier, readWebhookConfig, type WebhookUrgency } from '../webhook-notify.js';
+import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
@@ -197,6 +199,7 @@ import {
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
+ registerWebhookRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -368,6 +371,8 @@ export class WebServer extends EventEmitter {
private hookSecretFailures: StaleExpirationMap | null = null;
private userFailures: StaleExpirationMap | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
+ /** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
+ private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
@@ -1130,6 +1135,11 @@ export class WebServer extends EventEmitter {
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
+ registerWebhookRoutes(this.app, {
+ notifier: this.webhookNotifier,
+ configDir: getDataDir(),
+ hostTitle: () => this.windowTitle,
+ });
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -2652,6 +2662,25 @@ export class WebServer extends EventEmitter {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
+ const sessionName = (data.sessionName as string) || '';
+ const sessionId = (data.sessionId as string) || '';
+ const body = WebServer.pushBodyText(event, data, sessionName);
+
+ // Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
+ // the "no subscriptions" return below, which is exactly the headless-server case it exists for.
+ // Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
+ void this.webhookNotifier
+ .notify({
+ event,
+ title: template.title,
+ body,
+ urgency: template.urgency as WebhookUrgency,
+ sessionId: sessionId || undefined,
+ sessionName: sessionName || undefined,
+ host: this.windowTitle,
+ })
+ .catch(() => undefined);
+
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2670,9 +2699,6 @@ export class WebServer extends EventEmitter {
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
- const sessionName = (data.sessionName as string) || '';
- const sessionId = (data.sessionId as string) || '';
-
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
// must reach only the owner's devices (+ admins) — the body embeds the session
// name + activity, so cross-user delivery would leak it. Resolved once here; the
@@ -2680,25 +2706,6 @@ export class WebServer extends EventEmitter {
const multiUserPush = isMultiUserMode();
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
- // Build body text from event data
- let body = sessionName ? `[${sessionName}]` : '';
- if (event === SseEvent.SessionError && data.error) {
- body += body ? ' ' : '';
- body += String(data.error).slice(0, 200);
- } else if (event === SseEvent.RespawnBlocked && data.reason) {
- body += body ? ' ' : '';
- body += String(data.reason);
- } else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
- body += body ? ' ' : '';
- body += String(data.phrase);
- } else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
- body += body ? ' ' : '';
- body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
- } else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
- body += body ? ' ' : '';
- body += `Tool: ${String(data.tool_name)}`;
- }
-
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
@@ -2752,6 +2759,28 @@ export class WebServer extends EventEmitter {
}
}
+ /** The notification body for an event (shared by Web Push and the webhook channel). */
+ private static pushBodyText(event: string, data: Record, sessionName: string): string {
+ let body = sessionName ? `[${sessionName}]` : '';
+ if (event === SseEvent.SessionError && data.error) {
+ body += body ? ' ' : '';
+ body += String(data.error).slice(0, 200);
+ } else if (event === SseEvent.RespawnBlocked && data.reason) {
+ body += body ? ' ' : '';
+ body += String(data.reason);
+ } else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
+ body += body ? ' ' : '';
+ body += String(data.phrase);
+ } else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
+ body += body ? ' ' : '';
+ body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
+ } else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
+ body += body ? ' ' : '';
+ body += `Tool: ${String(data.tool_name)}`;
+ }
+ return body;
+ }
+
private cleanupDeadSSEClients(): void {
this.sse.cleanupDeadClients();
}
diff --git a/src/webhook-notify.ts b/src/webhook-notify.ts
new file mode 100644
index 000000000..db7f162a1
--- /dev/null
+++ b/src/webhook-notify.ts
@@ -0,0 +1,328 @@
+/**
+ * @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
+ * already trigger Web Push, so a headless server can reach a phone without a browser tab or a
+ * push subscription.
+ *
+ * Split in three, so the parts that matter are testable without a network:
+ * - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
+ * - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
+ * anyone holding a Slack/Discord webhook URL can post as it)
+ * - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
+ *
+ * Rules the code keeps and the tests pin:
+ * - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
+ * `settings.json`, which every logged-in user can read through `GET /api/settings`.
+ * - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
+ * the RESOLVED address at connect time, redirects are not followed, and the call is bounded
+ * by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
+ * - The URL never appears in a log line, a result, or an error message.
+ * - Session names and error text are user/agent-controlled, so they cannot ping a channel:
+ * Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
+ *
+ * @module webhook-notify
+ */
+
+import { existsSync, mkdirSync } from 'node:fs';
+import fs from 'node:fs/promises';
+import { join } from 'node:path';
+import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
+
+const WEBHOOK_FILE = 'webhook.json';
+const MAX_URL_LENGTH = 2048;
+const SEND_TIMEOUT_MS = 5000;
+const MAX_BODY_CHARS = 500;
+/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
+const DEDUPE_WINDOW_MS = 3000;
+const MAX_IN_FLIGHT = 5;
+
+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';
+
+export interface WebhookConfig {
+ enabled: boolean;
+ kind: WebhookKind;
+ url: string;
+ scope: WebhookScope;
+}
+
+export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
+
+export interface WebhookMessage {
+ event: string;
+ title: string;
+ body: string;
+ urgency: WebhookUrgency;
+ sessionId?: string;
+ sessionName?: string;
+ /** The Codeman instance's window title, so several machines are told apart. */
+ host?: string;
+}
+
+export interface WebhookResult {
+ ok: boolean;
+ status?: number;
+ error?: string;
+ at: number;
+}
+
+// ---------------------------------------------------------------------------
+// Pure
+// ---------------------------------------------------------------------------
+
+/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
+export function webhookUrlProblem(raw: string): string | null {
+ if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ return 'Not a valid URL';
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
+ if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
+ const blocked = blockedWebviewHostReason(url.hostname);
+ if (blocked) return `Refused: ${blocked}`;
+ return null;
+}
+
+/** Scheme + host only: the path and query of a webhook URL are the secret. */
+export function maskWebhookUrl(raw: string): string {
+ try {
+ const url = new URL(raw);
+ return `${url.protocol}//${url.host}/•••`;
+ } catch {
+ return '';
+ }
+}
+
+export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
+ if (!cfg.enabled || !cfg.url) return false;
+ return cfg.scope === 'all' || urgency !== 'info';
+}
+
+const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
+
+/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
+function headerSafe(value: string): string {
+ const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
+ return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
+}
+
+/** Slack parses ``, `<@U123>` and ``; escaping the three control characters turns them to text. */
+const slackEscape = (s: string): string => s.replace(/&/g, '&').replace(//g, '>');
+
+const NTFY_PRIORITY: Record = { critical: '5', warning: '4', info: '3' };
+const NTFY_TAGS: Record = {
+ critical: 'rotating_light',
+ warning: 'bell',
+ info: 'white_check_mark',
+};
+
+export interface WebhookRequest {
+ method: 'POST';
+ headers: Record;
+ body: string;
+}
+
+export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
+ const title = clip(msg.title, 120);
+ const body = clip(msg.body, MAX_BODY_CHARS);
+ const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
+ switch (kind) {
+ case 'ntfy':
+ return {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ Title: headerSafe(`${prefix}${title}`),
+ Priority: NTFY_PRIORITY[msg.urgency],
+ Tags: NTFY_TAGS[msg.urgency],
+ },
+ body: body || title,
+ };
+ case 'slack':
+ return {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
+ };
+ case 'discord':
+ return {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
+ // Agent output and session names are not trusted to @everyone a channel.
+ allowed_mentions: { parse: [] },
+ }),
+ };
+ case 'generic':
+ return {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ event: msg.event,
+ title,
+ body,
+ urgency: msg.urgency,
+ sessionId: msg.sessionId ?? null,
+ sessionName: msg.sessionName ?? null,
+ host: msg.host ?? null,
+ at: now.toISOString(),
+ }),
+ };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Store
+// ---------------------------------------------------------------------------
+
+export function webhookConfigPath(configDir: string): string {
+ return join(configDir, WEBHOOK_FILE);
+}
+
+function coerce(raw: unknown): WebhookConfig {
+ const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record;
+ return {
+ enabled: r.enabled === true,
+ kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
+ ? (r.kind as WebhookKind)
+ : DEFAULT_WEBHOOK_CONFIG.kind,
+ url: typeof r.url === 'string' ? r.url : '',
+ scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
+ ? (r.scope as WebhookScope)
+ : DEFAULT_WEBHOOK_CONFIG.scope,
+ };
+}
+
+export async function readWebhookConfig(configDir: string): Promise {
+ try {
+ return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
+ } catch {
+ return { ...DEFAULT_WEBHOOK_CONFIG };
+ }
+}
+
+/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
+export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise {
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
+ const target = webhookConfigPath(configDir);
+ const tmp = `${target}.${process.pid}.tmp`;
+ try {
+ await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
+ await fs.rename(tmp, target);
+ } catch (err) {
+ await fs.unlink(tmp).catch(() => undefined);
+ throw err;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// IO
+// ---------------------------------------------------------------------------
+
+export type WebhookFetch = (target: URL, init: RequestInit) => Promise;
+
+/** What went wrong, without the URL: blocked / timed out / refused / an HTTP status. */
+function describeError(err: unknown): string {
+ const e = err as { name?: string; message?: string; cause?: { code?: string; message?: string } };
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
+ const text = `${e?.message ?? ''} ${e?.cause?.message ?? ''}`;
+ if (/link-local|cloud-metadata|EGRESS/i.test(text))
+ return 'Refused: target is a link-local or cloud-metadata address';
+ if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
+ if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
+ return 'Network error';
+}
+
+export async function sendWebhook(
+ cfg: Pick,
+ msg: WebhookMessage,
+ fetchImpl: WebhookFetch
+): Promise {
+ const at = Date.now();
+ const problem = webhookUrlProblem(cfg.url);
+ if (problem) return { ok: false, error: problem, at };
+ const req = buildWebhookRequest(cfg.kind, msg);
+ try {
+ const res = await fetchImpl(new URL(cfg.url), {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ redirect: 'manual',
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
+ });
+ void res.body?.cancel().catch(() => undefined);
+ if (res.status >= 300 && res.status < 400) {
+ return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
+ }
+ return res.ok
+ ? { ok: true, status: res.status, at }
+ : { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
+ } catch (err) {
+ return { ok: false, error: describeError(err), at };
+ }
+}
+
+/**
+ * Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
+ * never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
+ * the last result for the Settings status line.
+ */
+export class WebhookNotifier {
+ private lastSent = new Map();
+ private inFlight = 0;
+ private last: WebhookResult | null = null;
+
+ constructor(
+ private readonly load: () => Promise,
+ private readonly fetchImpl: WebhookFetch,
+ private readonly now: () => number = Date.now
+ ) {}
+
+ get lastResult(): WebhookResult | null {
+ return this.last;
+ }
+
+ async notify(msg: WebhookMessage): Promise {
+ const cfg = await this.load();
+ if (!shouldSendWebhook(cfg, msg.urgency)) return;
+ const key = `${msg.event}:${msg.sessionId ?? ''}`;
+ const t = this.now();
+ const prev = this.lastSent.get(key);
+ if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
+ if (this.inFlight >= MAX_IN_FLIGHT) return;
+ this.lastSent.set(key, t);
+ if (this.lastSent.size > 256) {
+ for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
+ }
+ this.inFlight++;
+ try {
+ this.last = await sendWebhook(cfg, msg, this.fetchImpl);
+ } finally {
+ this.inFlight--;
+ }
+ }
+
+ /** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
+ async sendTest(cfg: Pick, host?: string): Promise {
+ const result = await sendWebhook(
+ cfg,
+ {
+ event: 'webhook:test',
+ title: 'Codeman test notification',
+ body: 'If you can read this, webhook notifications are working.',
+ urgency: 'info',
+ host,
+ },
+ this.fetchImpl
+ );
+ this.last = result;
+ return result;
+ }
+}
diff --git a/test/routes/webhook-routes.test.ts b/test/routes/webhook-routes.test.ts
new file mode 100644
index 000000000..58fa21325
--- /dev/null
+++ b/test/routes/webhook-routes.test.ts
@@ -0,0 +1,170 @@
+/**
+ * @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
+ * is never returned and the routes are admin only in multi-user mode.
+ * Port: N/A (app.inject()).
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createRouteTestHarness } from './_route-test-utils.js';
+import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
+import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
+
+const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
+
+let dir: string;
+let fetchImpl: ReturnType>;
+
+async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
+ const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
+ const h = await createRouteTestHarness(
+ (app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
+ authUser ? { authUser } : undefined
+ );
+ return { ...h, notifier };
+}
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
+ fetchImpl = vi.fn(async () => new Response('', { status: 200 }));
+});
+afterEach(() => {
+ delete process.env.CODEMAN_MULTIUSER;
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('GET /api/webhook', () => {
+ it('starts disabled with no URL', async () => {
+ const { app } = await harness();
+ const res = await app.inject({ method: 'GET', url: '/api/webhook' });
+ expect(res.json().data).toEqual({
+ enabled: false,
+ kind: 'ntfy',
+ scope: 'attention',
+ hasUrl: false,
+ urlMasked: '',
+ lastResult: null,
+ });
+ });
+});
+
+describe('PUT /api/webhook', () => {
+ it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
+ const { app } = await harness();
+ const put = await app.inject({
+ method: 'PUT',
+ url: '/api/webhook',
+ payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
+ });
+ expect(put.statusCode).toBe(200);
+ expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
+ expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
+ const get = await app.inject({ method: 'GET', url: '/api/webhook' });
+ for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
+ expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
+ expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
+ });
+
+ it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
+ const { app } = await harness();
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
+ expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
+ });
+
+ it('an empty url clears it', async () => {
+ const { app } = await harness();
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
+ const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
+ expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
+ expect((await readWebhookConfig(dir)).url).toBe('');
+ });
+
+ it.each([
+ ['enabling with no URL', { enabled: true }, /Add a webhook URL/],
+ ['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
+ ['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
+ ['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
+ ['clearing the URL while enabled', null, /Add a webhook URL/],
+ ])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
+ const { app } = await harness();
+ if (payload === null) {
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
+ const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().error).toMatch(why);
+ expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
+ return;
+ }
+ const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
+ expect(res.statusCode).toBe(400);
+ expect(res.json().error).toMatch(why);
+ expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
+ });
+
+ it('rejects unknown keys and bad enums (strict schema)', async () => {
+ const { app } = await harness();
+ for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
+ const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
+ expect(res.statusCode, JSON.stringify(payload)).toBe(400);
+ }
+ });
+});
+
+describe('POST /api/webhook/test', () => {
+ it('refuses with 400 until a URL is saved', async () => {
+ const { app } = await harness();
+ const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
+ expect(res.statusCode).toBe(400);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('sends one message with the saved config, even while notifications are disabled', async () => {
+ const { app, notifier } = await harness();
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
+ const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().data).toMatchObject({ ok: true, status: 200 });
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
+ host: 'codeman:test',
+ event: 'webhook:test',
+ });
+ expect(notifier.lastResult?.ok).toBe(true);
+ expect(res.body).not.toContain('SUPERSECRET');
+ });
+
+ it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
+ fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
+ const { app } = await harness();
+ await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
+ const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
+ const get = await app.inject({ method: 'GET', url: '/api/webhook' });
+ expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
+ expect(get.body).not.toContain('SUPERSECRET');
+ });
+});
+
+describe('multi-user', () => {
+ it.each([
+ ['GET', '/api/webhook'],
+ ['PUT', '/api/webhook'],
+ ['POST', '/api/webhook/test'],
+ ] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const { app } = await harness({ username: 'bob', role: 'user' });
+ const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
+ expect(res.statusCode).toBe(403);
+ expect((await readWebhookConfig(dir)).url).toBe('');
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('allows an admin', async () => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const { app } = await harness({ username: 'root', role: 'admin' });
+ expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
+ });
+});
diff --git a/test/webhook-notify.test.ts b/test/webhook-notify.test.ts
new file mode 100644
index 000000000..4dc2adab5
--- /dev/null
+++ b/test/webhook-notify.test.ts
@@ -0,0 +1,335 @@
+// @vitest-environment node
+import { createServer, type IncomingMessage, type Server } from 'node:http';
+import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { AddressInfo } from 'node:net';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ buildWebhookRequest,
+ DEFAULT_WEBHOOK_CONFIG,
+ maskWebhookUrl,
+ readWebhookConfig,
+ sendWebhook,
+ shouldSendWebhook,
+ webhookConfigPath,
+ WebhookNotifier,
+ webhookUrlProblem,
+ writeWebhookConfig,
+ type WebhookConfig,
+ type WebhookFetch,
+ type WebhookMessage,
+} from '../src/webhook-notify.js';
+import { webviewFetch } from '../src/web/webview-egress.js';
+
+const MSG: WebhookMessage = {
+ event: 'hook:permission_prompt',
+ title: 'Permission Required',
+ body: '[w1-app] Tool: Bash',
+ urgency: 'critical',
+ sessionId: 's1',
+ sessionName: 'w1-app',
+ host: 'codeman:box',
+};
+const CFG: WebhookConfig = {
+ enabled: true,
+ kind: 'generic',
+ url: 'https://hooks.example.com/T0/B0/secret',
+ scope: 'attention',
+};
+// A 204 (like a 3xx with no body) must be built without one.
+const ok = (status = 200) => new Response(status === 204 ? null : '', { status });
+
+describe('webhookUrlProblem', () => {
+ it.each([
+ 'https://ntfy.sh/mytopic',
+ 'https://hooks.slack.com/services/T/B/x',
+ 'http://localhost:8080/t',
+ 'http://192.168.1.5/hook',
+ ])('accepts %s (loopback and LAN are the point of a local ntfy)', (url) => expect(webhookUrlProblem(url)).toBeNull());
+
+ it.each([
+ ['ftp://example.com/x', /http and https/],
+ ['file:///etc/passwd', /http and https/],
+ ['https://user:pw@example.com/x', /credentials/],
+ ['not a url', /valid URL/],
+ ['http://169.254.169.254/latest/meta-data', /link-local|metadata/],
+ ['http://metadata.google.internal/computeMetadata/v1/', /metadata/],
+ ['http://[fd00:ec2::254]/', /metadata|link-local/],
+ [`https://example.com/${'a'.repeat(2100)}`, /too long/],
+ ])('rejects %s', (url, why) => expect(webhookUrlProblem(url)).toMatch(why));
+});
+
+describe('maskWebhookUrl', () => {
+ it('keeps scheme and host and drops the secret path and query', () => {
+ const masked = maskWebhookUrl('https://hooks.slack.com/services/T0/B0/XXXXSECRET?token=abc');
+ expect(masked).toBe('https://hooks.slack.com/•••');
+ expect(masked).not.toMatch(/SECRET|token|T0/);
+ });
+ it('is empty for nothing or garbage', () => {
+ expect(maskWebhookUrl('')).toBe('');
+ expect(maskWebhookUrl('nope')).toBe('');
+ });
+});
+
+describe('shouldSendWebhook', () => {
+ it('needs enabled and a url', () => {
+ expect(shouldSendWebhook({ ...CFG, enabled: false }, 'critical')).toBe(false);
+ expect(shouldSendWebhook({ ...CFG, url: '' }, 'critical')).toBe(false);
+ expect(shouldSendWebhook(CFG, 'critical')).toBe(true);
+ });
+ it('scope attention skips "response complete" (info); scope all sends it', () => {
+ expect(shouldSendWebhook(CFG, 'warning')).toBe(true);
+ expect(shouldSendWebhook(CFG, 'info')).toBe(false);
+ expect(shouldSendWebhook({ ...CFG, scope: 'all' }, 'info')).toBe(true);
+ });
+});
+
+describe('buildWebhookRequest', () => {
+ it('ntfy: plain-text body, priority and tag by urgency, host-prefixed title', () => {
+ const r = buildWebhookRequest('ntfy', MSG);
+ expect(r.body).toBe('[w1-app] Tool: Bash');
+ expect(r.headers.Title).toBe('codeman:box: Permission Required');
+ expect(r.headers.Priority).toBe('5');
+ expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'info' }).headers.Priority).toBe('3');
+ expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'warning' }).headers.Priority).toBe('4');
+ });
+
+ it('ntfy: a non-ASCII or multi-line title can never break the header (RFC 2047 encoded)', () => {
+ const r = buildWebhookRequest('ntfy', { ...MSG, title: 'Prüfung\r\nX-Injected: 1', host: undefined });
+ expect(r.headers.Title).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
+ expect(Buffer.from(r.headers.Title.slice(10, -2), 'base64').toString('utf8')).toBe('Prüfung X-Injected: 1');
+ expect(Object.keys(r.headers)).not.toContain('X-Injected');
+ });
+
+ it('slack: control characters are escaped so agent text cannot ping a channel', () => {
+ const r = JSON.parse(
+ buildWebhookRequest('slack', { ...MSG, body: ' <@U123> & more' }).body
+ );
+ expect(r.text).not.toMatch(/<[!@h]/);
+ expect(r.text).toContain('<!channel>');
+ expect(r.text).toContain('& more');
+ });
+
+ it('discord: mentions are disabled and the content is length-capped', () => {
+ const r = JSON.parse(buildWebhookRequest('discord', { ...MSG, body: '@everyone ' + 'x'.repeat(5000) }).body);
+ expect(r.allowed_mentions).toEqual({ parse: [] });
+ expect(r.content.length).toBeLessThanOrEqual(1900);
+ });
+
+ it('generic: structured JSON with the session and a timestamp', () => {
+ const r = JSON.parse(buildWebhookRequest('generic', MSG, new Date('2026-10-02T12:00:00Z')).body);
+ expect(r).toEqual({
+ event: 'hook:permission_prompt',
+ title: 'Permission Required',
+ body: '[w1-app] Tool: Bash',
+ urgency: 'critical',
+ sessionId: 's1',
+ sessionName: 'w1-app',
+ host: 'codeman:box',
+ at: '2026-10-02T12:00:00.000Z',
+ });
+ });
+
+ it('truncates a long body for every kind', () => {
+ const long = 'y'.repeat(2000);
+ expect(buildWebhookRequest('ntfy', { ...MSG, body: long }).body.length).toBeLessThanOrEqual(500);
+ expect(JSON.parse(buildWebhookRequest('generic', { ...MSG, body: long }).body).body.length).toBeLessThanOrEqual(
+ 500
+ );
+ });
+});
+
+describe('store', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'webhook-'));
+ });
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ it('returns the defaults for a missing or corrupt file', async () => {
+ expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
+ writeFileSync(webhookConfigPath(dir), '{ nope');
+ expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
+ });
+
+ it('round-trips and writes the file readable by its owner only', async () => {
+ await writeWebhookConfig(dir, CFG);
+ expect(await readWebhookConfig(dir)).toEqual(CFG);
+ expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
+ });
+
+ it('tightens an existing world-readable file instead of keeping its mode', async () => {
+ writeFileSync(webhookConfigPath(dir), '{}', { mode: 0o644 });
+ await writeWebhookConfig(dir, CFG);
+ expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
+ });
+
+ it('coerces unknown kinds and scopes back to the defaults', async () => {
+ writeFileSync(
+ webhookConfigPath(dir),
+ JSON.stringify({ enabled: true, kind: 'telegram', scope: 'nope', url: 'https://x.test/h' })
+ );
+ const cfg = await readWebhookConfig(dir);
+ expect(cfg).toEqual({ enabled: true, kind: 'ntfy', scope: 'attention', url: 'https://x.test/h' });
+ });
+});
+
+describe('sendWebhook', () => {
+ it('posts the built request with redirects off and a timeout signal', async () => {
+ const fetchImpl = vi.fn(async () => ok(204));
+ const r = await sendWebhook(CFG, MSG, fetchImpl);
+ expect(r).toMatchObject({ ok: true, status: 204 });
+ const [target, init] = fetchImpl.mock.calls[0];
+ expect(target.href).toBe(CFG.url);
+ expect(init.method).toBe('POST');
+ expect(init.redirect).toBe('manual');
+ expect(init.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ it('reports an HTTP failure by status, a redirect as such, and never echoes the URL', async () => {
+ const bad = await sendWebhook(CFG, MSG, async () => ok(404));
+ expect(bad).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
+ const redirect = await sendWebhook(CFG, MSG, async () => ok(302));
+ expect(redirect.ok).toBe(false);
+ expect(redirect.error).toMatch(/redirects/);
+ for (const r of [bad, redirect]) expect(JSON.stringify(r)).not.toContain('secret');
+ });
+
+ it('turns network errors into short messages that do not contain the URL', async () => {
+ const cases: [unknown, RegExp][] = [
+ [Object.assign(new Error('x'), { name: 'TimeoutError' }), /Timed out/],
+ [Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }), /Host not found/],
+ [Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }), /refused/],
+ [new TypeError('fetch failed https://hooks.example.com/T0/B0/secret'), /Network error/],
+ ];
+ for (const [err, re] of cases) {
+ const r = await sendWebhook(CFG, MSG, async () => {
+ throw err;
+ });
+ expect(r.ok).toBe(false);
+ expect(r.error).toMatch(re);
+ expect(JSON.stringify(r)).not.toContain('secret');
+ }
+ });
+
+ it('refuses a blocked URL without fetching', async () => {
+ const fetchImpl = vi.fn(async () => ok());
+ const r = await sendWebhook({ ...CFG, url: 'http://169.254.169.254/latest' }, MSG, fetchImpl);
+ expect(r.ok).toBe(false);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+});
+
+describe('delivery through the real egress-guarded fetch', () => {
+ let server: Server;
+ let received: { headers: IncomingMessage['headers']; body: string } | null;
+ let port: number;
+
+ beforeEach(async () => {
+ received = null;
+ server = createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ received = { headers: req.headers, body };
+ res.statusCode = req.url === '/redirect' ? 302 : 200;
+ if (req.url === '/redirect') res.setHeader('Location', 'http://169.254.169.254/');
+ res.end('ok');
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ port = (server.address() as AddressInfo).port;
+ });
+ afterEach(() => new Promise((resolve) => server.close(() => resolve())));
+
+ it('delivers to a local server (loopback is allowed) with the ntfy headers', async () => {
+ const r = await sendWebhook({ kind: 'ntfy', url: `http://127.0.0.1:${port}/topic` }, MSG, webviewFetch);
+ expect(r).toMatchObject({ ok: true, status: 200 });
+ expect(received?.body).toBe('[w1-app] Tool: Bash');
+ expect(received?.headers.priority).toBe('5');
+ });
+
+ it('does not follow a redirect to a metadata address', async () => {
+ const r = await sendWebhook({ kind: 'generic', url: `http://127.0.0.1:${port}/redirect` }, MSG, webviewFetch);
+ expect(r.ok).toBe(false);
+ expect(r.error).toMatch(/redirects/);
+ });
+
+ it('refuses a metadata address at the fetch layer too', async () => {
+ await expect(webviewFetch(new URL('http://169.254.169.254/'))).rejects.toThrow();
+ });
+});
+
+describe('WebhookNotifier', () => {
+ const make = (cfg: Partial = {}, fetchImpl: WebhookFetch = async () => ok(), now?: () => number) => {
+ const sent = vi.fn(fetchImpl);
+ const notifier = new WebhookNotifier(async () => ({ ...CFG, ...cfg }), sent, now);
+ return { notifier, sent };
+ };
+
+ it('sends an event that needs attention and records the result', async () => {
+ const { notifier, sent } = make();
+ await notifier.notify(MSG);
+ expect(sent).toHaveBeenCalledTimes(1);
+ expect(notifier.lastResult).toMatchObject({ ok: true });
+ });
+
+ it('sends nothing when disabled, without a url, or for info under scope attention', async () => {
+ for (const cfg of [{ enabled: false }, { url: '' }]) {
+ const { notifier, sent } = make(cfg);
+ await notifier.notify(MSG);
+ expect(sent).not.toHaveBeenCalled();
+ }
+ const { notifier, sent } = make();
+ await notifier.notify({ ...MSG, urgency: 'info' });
+ expect(sent).not.toHaveBeenCalled();
+ });
+
+ it('sends the same event for the same session once per window, then again', async () => {
+ let t = 1_000_000;
+ const { notifier, sent } = make(
+ {},
+ async () => ok(),
+ () => t
+ );
+ await notifier.notify(MSG);
+ t += 1000;
+ await notifier.notify(MSG);
+ expect(sent).toHaveBeenCalledTimes(1);
+ await notifier.notify({ ...MSG, sessionId: 's2' });
+ expect(sent).toHaveBeenCalledTimes(2);
+ t += 5000;
+ await notifier.notify(MSG);
+ expect(sent).toHaveBeenCalledTimes(3);
+ });
+
+ it('caps what is in flight so a hung endpoint cannot pile up requests', async () => {
+ let release: () => void = () => undefined;
+ const gate = new Promise((r) => (release = r));
+ const { notifier, sent } = make({}, async () => (await gate, ok()));
+ const pending = Array.from({ length: 12 }, (_, i) => notifier.notify({ ...MSG, sessionId: `s${i}` }));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(sent).toHaveBeenCalledTimes(5);
+ release();
+ await Promise.all(pending);
+ });
+
+ it('a test send ignores enabled and scope, bypasses dedupe, and records the result', async () => {
+ const { notifier, sent } = make({ enabled: false });
+ const r1 = await notifier.sendTest(CFG, 'codeman:box');
+ const r2 = await notifier.sendTest(CFG);
+ expect(r1.ok && r2.ok).toBe(true);
+ expect(sent).toHaveBeenCalledTimes(2);
+ expect(notifier.lastResult).toBe(r2);
+ expect(JSON.parse(sent.mock.calls[0][1].body as string).host).toBe('codeman:box');
+ });
+
+ it('never throws on a failing endpoint', async () => {
+ const { notifier } = make({}, async () => {
+ throw new Error('boom');
+ });
+ await expect(notifier.notify(MSG)).resolves.toBeUndefined();
+ expect(notifier.lastResult).toMatchObject({ ok: false });
+ });
+});
diff --git a/test/webhook-settings.browser.test.ts b/test/webhook-settings.browser.test.ts
new file mode 100644
index 000000000..1832b3829
--- /dev/null
+++ b/test/webhook-settings.browser.test.ts
@@ -0,0 +1,119 @@
+/** @fileoverview Settings → Notifications → Webhook, end to end: real server, real Chromium, a local receiver. */
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const PORT = 3195;
+const SECRET = 'SUPERSECRET-topic-123';
+
+describe('Webhook settings in a real browser', () => {
+ let server: WebServer;
+ let browser: Browser;
+ let page: Page;
+ let receiver: Server;
+ let receiverPort: number;
+ let respondWith = 200;
+ const got: { url?: string; title?: string; body: string }[] = [];
+
+ beforeAll(async () => {
+ receiver = createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ got.push({ url: req.url, title: req.headers.title as string | undefined, body });
+ res.statusCode = respondWith;
+ res.end('x');
+ });
+ });
+ await new Promise((r) => receiver.listen(0, '127.0.0.1', r));
+ receiverPort = (receiver.address() as AddressInfo).port;
+
+ server = new WebServer(PORT, false, true);
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage();
+ await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
+ await page.evaluate(() => (window as any).app.openAppSettings());
+ await page.waitForSelector('#webhookGroup', { state: 'attached' });
+ await page.waitForFunction(() => document.getElementById('webhookGroup')!.style.display !== 'none');
+ }, 90000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ if (server) await server.stop();
+ await new Promise((r) => receiver.close(() => r()));
+ }, 60000);
+
+ const result = () => page.textContent('#webhookResult');
+
+ // The checkbox sits behind a styled slider, so click the switch like a user does.
+ const setSwitch = async (on: boolean) => {
+ if ((await page.isChecked('#webhookEnabled')) !== on) await page.click('label.switch:has(#webhookEnabled)');
+ expect(await page.isChecked('#webhookEnabled')).toBe(on);
+ };
+
+ it('shows the group, starts empty, and refuses to enable without a URL', async () => {
+ expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
+ await setSwitch(true);
+ await page.click('#webhookSaveBtn');
+ await page.waitForFunction(() =>
+ /Add a webhook URL/.test(document.getElementById('webhookResult')?.textContent ?? '')
+ );
+ await setSwitch(false);
+ });
+
+ it('refuses a cloud-metadata URL with the server’s reason', async () => {
+ await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
+ await page.click('#webhookSaveBtn');
+ await page.waitForFunction(() =>
+ /metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
+ );
+ });
+
+ it('saves a URL, shows only scheme and host, and empties the secret field', async () => {
+ await page.selectOption('#webhookKind', 'ntfy');
+ await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/${SECRET}`);
+ await setSwitch(true);
+ await page.click('#webhookSaveBtn');
+ await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
+ expect(await page.textContent('#webhookUrlHint')).toBe(`Saved: http://127.0.0.1:${receiverPort}/•••`);
+ expect(await page.inputValue('#webhookUrl')).toBe('');
+ expect(await page.content()).not.toContain(SECRET);
+ // ...and GET /api/webhook never returns it either.
+ const body = await page.evaluate(async () => (await fetch('/api/webhook')).text());
+ expect(body).not.toContain('SUPERSECRET');
+ });
+
+ it('sends a test message that reaches the receiver with the ntfy headers', async () => {
+ got.length = 0;
+ await page.click('#webhookTestBtn');
+ await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
+ expect(got).toHaveLength(1);
+ expect(got[0].url).toBe(`/${SECRET}`);
+ expect(got[0].title).toMatch(/Codeman test notification/);
+ expect(got[0].body).toMatch(/webhook notifications are working/);
+ });
+
+ it('reports a failing endpoint without exposing the URL', async () => {
+ respondWith = 500;
+ await page.click('#webhookTestBtn');
+ await page.waitForFunction(() =>
+ /Delivery failed: HTTP 500/.test(document.getElementById('webhookResult')?.textContent ?? '')
+ );
+ expect(await result()).not.toContain(SECRET);
+ respondWith = 200;
+ });
+
+ it('keeps the saved URL when only the service changes', async () => {
+ got.length = 0;
+ await page.selectOption('#webhookKind', 'generic');
+ await page.click('#webhookSaveBtn');
+ await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
+ await page.click('#webhookTestBtn');
+ await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
+ expect(JSON.parse(got[0].body)).toMatchObject({ event: 'webhook:test', urgency: 'info' });
+ });
+});