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