diff --git a/.changeset/webhook-notifications.md b/.changeset/webhook-notifications.md new file mode 100644 index 00000000..ae9ffab1 --- /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 fa08ec99..d473797e 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/split-pane-terminal.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.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 322b614d..e9e532ab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -733,6 +733,18 @@ Result (`data`): The result carries server **names** only, never `env` values or `headers`. Each changed file keeps its previous content as `.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. +## 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 2c138ec9..91d3694f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2641,6 +2641,53 @@ + + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index bd95d64a..a6f0dd14 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -419,6 +419,7 @@ Object.assign(CodemanApp.prototype, { // MCP server sync: synced, default OFF; same explicit-sync reasoning as above. document.getElementById('appSettingsMcpSync').checked = settings.mcpSyncEnabled === true; this.applyMcpSyncVisibility(); + 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 = @@ -1188,6 +1189,88 @@ Object.assign(CodemanApp.prototype, { show(`${conflicts}${disabled}${unsupported}`); }, + /** + * 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 583fb112..a8886041 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -29,6 +29,7 @@ export { registerVoiceRoutes } from './voice-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-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 00000000..e1bd33cc --- /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 a4c3655d..5f409b65 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1834,6 +1834,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 31034563..98d55f13 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'; @@ -198,6 +200,7 @@ import { registerWebviewRoutes, registerTabLayoutRoutes, registerMcpSyncRoutes, + registerWebhookRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, @@ -369,6 +372,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; @@ -1132,6 +1137,11 @@ export class WebServer extends EventEmitter { registerWebviewRoutes(this.app, ctx, this.basePath); registerTabLayoutRoutes(this.app, ctx); registerMcpSyncRoutes(this.app); + registerWebhookRoutes(this.app, { + notifier: this.webhookNotifier, + configDir: getDataDir(), + hostTitle: () => this.windowTitle, + }); registerCustomModelRoutes(this.app); registerCliRegistryRoutes(this.app); @@ -2654,6 +2664,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; @@ -2672,9 +2701,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 @@ -2682,25 +2708,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 @@ -2754,6 +2761,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 00000000..db7f162a --- /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 00000000..58fa2132 --- /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 00000000..4dc2adab --- /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 00000000..1832b382 --- /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' }); + }); +});