Files
Codeman/test/webhook-push-glue.test.ts
Codeman maintainer a35ddd82ba feat(codex): raise a screen-read dialog as BLOCKED, alert-only
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.

It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.

The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.

Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:18:37 +02:00

146 lines
6.3 KiB
TypeScript

/**
* @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('carries a screen-read dialog (codex) with its own message, and a hook message stays out', async () => {
// web/screen-blocked.ts sends the CLI's own label, built server-side, never the
// dialog's text. A Claude hook's `message` keeps the body it always had.
const { send } = await headlessServer({});
await send('hook:permission_prompt', {
sessionId: 's3',
sessionName: 'w3-app',
source: 'screen',
message: 'Codex is waiting for your answer in the terminal',
});
await send('hook:permission_prompt', {
sessionId: 's4',
sessionName: 'w4-app',
message: 'Claude needs your permission to use Bash',
});
expect(got.map((g) => g.body)).toEqual(['[w3-app] Codex is waiting for your answer in the terminal', '[w4-app]']);
});
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);
});
});