Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/webhook-notifications.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
];
Expand Down
12 changes: 12 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,18 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |

## Webhook notifications

Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.

| Method | Path | Body | Notes |
| ------ | -------------------- | -------------------------------------------- | ----- |
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |

Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.

## Voice dictation

Browser dictation transcribed through this server's Claude Code login, i.e. the
Expand Down
47 changes: 47 additions & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2601,6 +2601,53 @@ <h2>Notifications</h2>
</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>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<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">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Save and test</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>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>

<!-- ══ Voice ════════════════════════════════════════════════════ -->
Expand Down
83 changes: 83 additions & 0 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
Expand Down Expand Up @@ -1114,6 +1115,88 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},

/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},

async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},

async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not save the webhook.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Saved.');
},

async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
this._webhookSay('Sending…');
try {
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},

_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
Expand Down
1 change: 1 addition & 0 deletions src/web/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
Expand Down
115 changes: 115 additions & 0 deletions src/web/routes/webhook-routes.ts
Original file line number Diff line number Diff line change
@@ -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<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};

const status = async (): Promise<WebhookStatus> => {
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<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});

app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
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<ApiResponse<WebhookResult>> => {
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()) };
});
}
13 changes: 13 additions & 0 deletions src/web/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,19 @@ export const RespawnEnableSchema = z.object({
// ========== Web Push ==========

/** POST /api/push/subscribe */
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it.
*/
export const WebhookUpdateSchema = z
.object({
enabled: z.boolean().optional(),
kind: z.enum(['ntfy', 'slack', 'discord', 'generic']).optional(),
scope: z.enum(['attention', 'all']).optional(),
url: z.string().max(2048).optional(),
})
.strict();

export const PushSubscribeSchema = z.object({
endpoint: z
.string()
Expand Down
Loading
Loading