mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
fix(notifications): main Save keeps webhook edits, glue test, docs and nits (#523 review)
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).
Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.
Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.
Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.
Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).
Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<username>/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/<username>/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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
*
|
||||
|
||||
+43
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2654,14 +2654,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="set-group" id="webhookGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Send alerts to a webhook</span>
|
||||
<span class="set-row-desc">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.</span>
|
||||
<span class="set-row-desc">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. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2679,7 +2679,7 @@
|
||||
<span class="set-row-label">Webhook URL</span>
|
||||
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="webhookUrl" class="set-select" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
|
||||
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
@@ -2692,10 +2692,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text"><span class="set-row-label">Save and test</span></div>
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
|
||||
@@ -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 <select> does not offer compares as what is shown.
|
||||
this._webhookLoaded = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
kind: document.getElementById('webhookKind').value,
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
this._webhookSay(
|
||||
@@ -1256,6 +1271,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** True when the visible webhook group differs from what loadWebhook() last showed. */
|
||||
_webhookPending() {
|
||||
const group = document.getElementById('webhookGroup');
|
||||
const loaded = this._webhookLoaded;
|
||||
if (!group || group.style.display === 'none' || !loaded) return false;
|
||||
return (
|
||||
document.getElementById('webhookUrl').value.trim() !== '' ||
|
||||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
|
||||
document.getElementById('webhookKind').value !== loaded.kind ||
|
||||
document.getElementById('webhookScope').value !== loaded.scope
|
||||
);
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveWebhook() {
|
||||
const payload = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
@@ -1263,23 +1292,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
const url = document.getElementById('webhookUrl').value.trim();
|
||||
if (url) payload.url = url; // blank = keep the saved one
|
||||
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
|
||||
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;
|
||||
const error = body?.error || 'Could not save the webhook.';
|
||||
this._webhookSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Saved.');
|
||||
return '';
|
||||
},
|
||||
|
||||
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
|
||||
async clearWebhook() {
|
||||
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
|
||||
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
|
||||
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 remove the webhook URL.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Webhook URL removed.');
|
||||
},
|
||||
|
||||
async testWebhook() {
|
||||
const btn = document.getElementById('webhookTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
this._webhookSay('Sending…');
|
||||
try {
|
||||
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
|
||||
this._webhookSay('Sending…');
|
||||
const res = await this._apiPost('/api/webhook/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
@@ -2613,6 +2659,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2637,7 +2684,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
this.showToast('Settings saved', 'success');
|
||||
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
|
||||
// settings.json), so like the model config above it is saved beside the settings PUT, not in
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else {
|
||||
this.showToast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
// Show tunnel-specific feedback if toggled on
|
||||
if (settings.tunnelEnabled) {
|
||||
@@ -2648,7 +2704,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
this.closeAppSettings();
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
// Voice availability is a server-side answer, so re-probe after a save:
|
||||
// otherwise the mic keeps using the pre-save provider until the next reload.
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type WebhookResult,
|
||||
type WebhookStatus,
|
||||
} from '../../types.js';
|
||||
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { WebhookUpdateSchema } from '../schemas.js';
|
||||
@@ -20,22 +27,9 @@ import {
|
||||
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;
|
||||
|
||||
+15
-13
@@ -19,6 +19,7 @@ import {
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
@@ -1874,19 +1875,6 @@ 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()
|
||||
@@ -1906,6 +1894,20 @@ export const PushPreferencesUpdateSchema = z.object({
|
||||
pushPreferences: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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. The kind and scope
|
||||
* lists are the store's own, so the schema can never accept a value the store would coerce away.
|
||||
*/
|
||||
export const WebhookUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
kind: z.enum(WEBHOOK_KINDS).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
url: z.string().max(2048).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
|
||||
+2
-1
@@ -44,7 +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 { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
|
||||
import type { WebhookUrgency } from '../types/push.js';
|
||||
import { webviewFetch } from './webview-egress.js';
|
||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||
import type { RemoteWakeRegistry } from '../remote-wake.js';
|
||||
|
||||
+17
-26
@@ -26,6 +26,16 @@ 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';
|
||||
import { isEgressBlockedError } from './web/webview-egress.js';
|
||||
import {
|
||||
WEBHOOK_KINDS,
|
||||
WEBHOOK_SCOPES,
|
||||
type WebhookConfig,
|
||||
type WebhookKind,
|
||||
type WebhookResult,
|
||||
type WebhookScope,
|
||||
type WebhookUrgency,
|
||||
} from './types/push.js';
|
||||
|
||||
const WEBHOOK_FILE = 'webhook.json';
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
@@ -35,20 +45,6 @@ const MAX_BODY_CHARS = 500;
|
||||
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 {
|
||||
@@ -62,13 +58,6 @@ export interface WebhookMessage {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,13 +217,15 @@ export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig):
|
||||
|
||||
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
|
||||
|
||||
/** What went wrong, without the URL: blocked / timed out / refused / an HTTP status. */
|
||||
/**
|
||||
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
|
||||
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
|
||||
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
|
||||
*/
|
||||
function describeError(err: unknown): string {
|
||||
const e = err as { name?: string; message?: string; cause?: { code?: string; message?: string } };
|
||||
const e = err as { name?: string; cause?: { code?: 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 (isEgressBlockedError(err)) 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';
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
WebhookNotifier,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookConfig,
|
||||
type WebhookFetch,
|
||||
type WebhookMessage,
|
||||
} from '../src/webhook-notify.js';
|
||||
import { webviewFetch } from '../src/web/webview-egress.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
import { webviewFetch, WebviewEgressBlockedError } from '../src/web/webview-egress.js';
|
||||
|
||||
const MSG: WebhookMessage = {
|
||||
event: 'hook:permission_prompt',
|
||||
@@ -219,6 +219,24 @@ describe('sendWebhook', () => {
|
||||
expect(r.ok).toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recognises an egress refusal by its code anywhere in the cause chain, not by message text', async () => {
|
||||
// A DNS name resolving into a blocked range is refused by the connect-time lookup, and undici
|
||||
// hands that back wrapped (`fetch failed` -> connect error -> the refusal), so it can sit deep.
|
||||
const blocked = new WebviewEgressBlockedError('cloud metadata');
|
||||
const deep = new TypeError('fetch failed', { cause: new Error('connect failed', { cause: blocked }) });
|
||||
for (const err of [blocked, deep]) {
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw err;
|
||||
});
|
||||
expect(r.error).toBe('Refused: target is a link-local or cloud-metadata address');
|
||||
}
|
||||
// Words alone (an upstream error that happens to mention them) are not a refusal.
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw new TypeError('fetch failed', { cause: new Error('link-local EGRESS hiccup') });
|
||||
});
|
||||
expect(r.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery through the real egress-guarded fetch', () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @fileoverview The glue between `WebServer.sendPushNotifications()` and the webhook channel.
|
||||
*
|
||||
* The notifier and the routes are tested on their own (webhook-notify.test.ts,
|
||||
* routes/webhook-routes.test.ts); this file pins the one line that makes the feature exist: the
|
||||
* webhook fires even when there is NO Web Push subscription, which is exactly the headless server
|
||||
* it was built for. Moving the call below the "no subscriptions" early return passes every other
|
||||
* test and silently kills the feature, so it is asserted here end to end: a real WebServer (never
|
||||
* started), an empty push store, `webhook.json` in the instance data dir, and a local HTTP receiver
|
||||
* reached through the real egress-guarded fetch.
|
||||
*
|
||||
* Port: N/A (no server start; the receiver binds an ephemeral 127.0.0.1 port).
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeWebhookConfig, type WebhookMessage, type WebhookNotifier } from '../src/webhook-notify.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
|
||||
interface Received {
|
||||
url?: string;
|
||||
title?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type PushSender = { sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void> };
|
||||
|
||||
let receiver: Server;
|
||||
let receiverUrl: string;
|
||||
const got: Received[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
receiver = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
// Recorded before the response goes out, so a resolved notify() means it is already here.
|
||||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => receiver.listen(0, '127.0.0.1', resolve));
|
||||
receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/codeman-topic`;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => receiver.close(() => resolve())));
|
||||
|
||||
beforeEach(() => {
|
||||
got.length = 0;
|
||||
sendNotification.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A headless server: no push subscription at all. The real notifier stays in place; its `notify`
|
||||
* is only wrapped to collect the promises `sendPushNotifications` fires and forgets, so a test can
|
||||
* await delivery instead of sleeping.
|
||||
*/
|
||||
async function headlessServer(cfg: Partial<WebhookConfig>) {
|
||||
await writeWebhookConfig(getDataDir(), { enabled: true, kind: 'ntfy', scope: 'attention', url: receiverUrl, ...cfg });
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', 'box');
|
||||
(server as unknown as { pushStore: { getAll: () => never[] } }).pushStore = { getAll: () => [] };
|
||||
const notifier = (server as unknown as { webhookNotifier: WebhookNotifier }).webhookNotifier;
|
||||
const pending: Promise<void>[] = [];
|
||||
const realNotify = notifier.notify.bind(notifier);
|
||||
notifier.notify = (msg: WebhookMessage) => {
|
||||
const p = realNotify(msg);
|
||||
pending.push(p);
|
||||
return p;
|
||||
};
|
||||
const send = async (event: string, data: Record<string, unknown>) => {
|
||||
await (server as unknown as PushSender).sendPushNotifications(event, data);
|
||||
await Promise.all(pending.splice(0));
|
||||
};
|
||||
return { send };
|
||||
}
|
||||
|
||||
describe('sendPushNotifications -> webhook (the headless case)', () => {
|
||||
it('delivers a permission prompt to the webhook with zero push subscriptions', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]).toEqual({
|
||||
url: '/codeman-topic',
|
||||
title: 'codeman:box: Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
});
|
||||
// Web Push had nobody to send to, and the webhook did not need it.
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedupes an immediate repeat of the same event for the same session', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
await send('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2-app' });
|
||||
expect(got).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips "response complete" under scope attention and sends it under scope all', async () => {
|
||||
const attention = await headlessServer({ scope: 'attention' });
|
||||
await attention.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
|
||||
const all = await headlessServer({ scope: 'all' });
|
||||
await all.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].title).toBe('codeman:box: Response Complete');
|
||||
});
|
||||
|
||||
it('sends nothing while disabled, and nothing for an event Web Push does not carry', async () => {
|
||||
const disabled = await headlessServer({ enabled: false });
|
||||
await disabled.send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
const enabled = await headlessServer({});
|
||||
await enabled.send('session:created', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -116,4 +116,50 @@ describe('Webhook settings in a real browser', () => {
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(JSON.parse(got[0].body)).toMatchObject({ event: 'webhook:test', urgency: 'info' });
|
||||
});
|
||||
|
||||
const savedWebhook = () => page.evaluate(async () => (await (await fetch('/api/webhook')).json()).data);
|
||||
const modalOpen = () =>
|
||||
page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
|
||||
it('the main Settings Save also saves a pending webhook edit', async () => {
|
||||
await page.selectOption('#webhookScope', 'all');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() => !document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
expect(await savedWebhook()).toMatchObject({ scope: 'all', kind: 'generic', enabled: true, hasUrl: true });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.waitForFunction(() => (window as any).app._webhookLoaded?.scope === 'all');
|
||||
});
|
||||
|
||||
it('a refused webhook keeps the modal open with the pasted URL, instead of a silent success', async () => {
|
||||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() =>
|
||||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
expect(await modalOpen()).toBe(true);
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('http://169.254.169.254/latest');
|
||||
expect((await savedWebhook()).urlMasked).toBe(`http://127.0.0.1:${receiverPort}/•••`);
|
||||
await page.fill('#webhookUrl', '');
|
||||
});
|
||||
|
||||
it('Send test saves a newly pasted URL first, so it never tests the old one', async () => {
|
||||
got.length = 0;
|
||||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/other-topic`);
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].url).toBe('/other-topic');
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||||
});
|
||||
|
||||
it('Remove URL deletes the saved secret and turns the channel off', async () => {
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(true);
|
||||
page.once('dialog', (d) => void d.accept());
|
||||
await page.click('#webhookClearBtn');
|
||||
await page.waitForFunction(() => /removed/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||||
expect(await page.isChecked('#webhookEnabled')).toBe(false);
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(false);
|
||||
expect(await savedWebhook()).toMatchObject({ hasUrl: false, enabled: false, urlMasked: '' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user