From af6af332c5a1e2e9c1d289cb4dac2c4559655b32 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Mon, 28 Sep 2026 22:43:28 -0400 Subject: [PATCH 01/15] docs(notifications): specify act buttons, Discord bot mode, the ntfy reply topic and Telegram Rich Messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-38 bot mode, D-40 Rich Messages with a classic fallback, new D-41 (single-use command tokens, allow-lists, presses over outbound connections) and D-42 (ntfy answers through a second topic, after the spike). Spec 03 gains schema v6, the press flow (§9.6) and the Discord setup and action audit endpoints; 04, 08, 09 and 10 follow. --- specs/00-decisions.md | 67 ++++++++++++--- specs/02-mcp-and-tools.md | 2 +- specs/03-admin-backend.md | 100 ++++++++++++++++++----- specs/04-admin-frontend.md | 17 ++-- specs/08-cli-arguments-and-config.md | 11 ++- specs/09-testing.md | 5 +- specs/10-error-handling-and-telemetry.md | 6 +- 7 files changed, 162 insertions(+), 46 deletions(-) diff --git a/specs/00-decisions.md b/specs/00-decisions.md index b9360d2..55dc30c 100644 --- a/specs/00-decisions.md +++ b/specs/00-decisions.md @@ -664,7 +664,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA - Every user brings their own Telegram bot, Discord webhook or bot and ntfy topic, and has full authority over it. - **Secrets live only in the environment.** A channel stores the *names* of environment variables (`secret_refs_json`, `token=env:BH_TG_TOKEN` in a startup channel), never a value, so a database backup never contains a token. An inline secret is refused: over the API with a validation error, on the command line with exit 64. The dashboard shows whether a variable is set, never its value. Names starting with `BROWSERHIVE_` are refused, because the config loader reserves that prefix. -**Consequences.** Changing a token is an environment change plus a restart. Features that seem to need an inbound connection are designed without one (act buttons through Telegram callbacks, the Discord gateway or a second ntfy topic, D-38). +**Consequences.** Changing a token is an environment change plus a restart. Features that seem to need an inbound connection are designed without one: act buttons arrive through Telegram long polling, the Discord gateway or a second ntfy topic (D-38, D-41, D-42). **Alternatives considered.** *A shared BrowserHive bot*: users would share a rate limit and trust a third party with every message. *Storing tokens encrypted in the database*: the key would have to live next to the database, so a backup would still carry both. @@ -727,11 +727,19 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Status:** Accepted -**Implementation:** webhook mode implemented in N1 (the renderer already draws bot mode for the setup's comparison); bot mode with act buttons in N2; `notification_channels.mode` since N0. +**Implementation:** webhook mode since the first channels (N1); bot mode with act buttons since N2; `notification_channels.mode` since N0. **Context.** A Discord webhook takes 30 seconds to create and needs no connection, but its messages cannot carry interactive buttons. A bot needs a Developer Portal application and a gateway connection, and is the only way to receive button presses without a public endpoint (an Interactions Endpoint URL and the gateway are mutually exclusive). -**Decision.** Each Discord channel uses exactly one mode: **webhook** (default: open links only; act buttons degrade to "Open in BrowserHive") or **bot** (opt-in: act buttons over the gateway WebSocket while BrowserHive runs). Switching mode is an edit of the channel and keeps its rules. The setup explains the difference with pictures of BrowserHive's own messages, never images copied from Discord or the web. +**Decision.** +- Each Discord channel uses exactly one mode: **webhook** (default: open links only; act buttons degrade to "Open in BrowserHive") or **bot** (opt-in: act buttons over the gateway WebSocket while BrowserHive runs). A webhook-mode channel names a `webhook` variable; a bot-mode channel names a `token` variable and a target `channel_id` (with `guild_id` and display names). Switching mode is an edit of the channel that replaces the other mode's variable and keeps the rules. +- Bot mode sends, edits and deletes through the bot REST API (`POST /channels/{id}/messages`, `PATCH`/`DELETE …/messages/{id}`) with the same embed as webhook mode (D-40). The setup builds the invite link with the minimal permissions (View Channel, Send Messages, Embed Links, Attach Files: `52224`), lists the bot's servers and their text channels through the bot API, and links the operator's Discord account with a one-time "This is me" button, whose press becomes the first allow-list entry (D-41). +- Presses arrive as `INTERACTION_CREATE` over the gateway, which BrowserHive implements itself (Hello, Identify with intents `0`, heartbeat with zombie detection, Resume, Reconnect, Invalid Session, fatal close codes) on Bun's WebSocket client with no dependency. Every press is answered within Discord's 3-second window: an ephemeral reply when the command finishes in 2 s, otherwise a deferred ephemeral reply edited when it does. The connection reconnects with backoff and resumes where Discord allows it; its state (connected, reconnecting, offline) shows on the channel card. One gateway connection serves every channel of the same bot. +- The setup explains the two modes with pictures of BrowserHive's own messages, never images copied from Discord or the web. + +**Consequences.** A bot token is a secret like any other (D-33). While BrowserHive is stopped, Discord shows "This interaction failed" for a press; nothing is queued. + +**Alternatives considered.** *An Interactions Endpoint URL*: needs a public HTTPS endpoint (D-33). *discord.js*: a large dependency for four gateway opcodes. ## D-39 Startup channels come from command-line flags only and are read-only @@ -745,18 +753,57 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Alternatives considered.** *A `notificationChannels` config key with a URI grammar* (the research's P1): secrets would sit in a file that gets committed, and per-channel rules would need a grammar the config ladder does not have. *Seeding the database from config on first run*: friendlier once, but the file would then lie about what is configured. -## D-40 Platform message formats: Telegram HTML messages, Discord embeds +## D-40 Platform message formats: Telegram Rich Messages with a classic fallback, Discord embeds + +**Status:** Accepted + +**Implementation:** Discord embeds and the classic Telegram renderer since the first channels (N1); Telegram Rich Messages since N2. + +**Context.** Telegram's Rich Messages (`sendRichMessage`, Bot API 10.1, June 2026; blocks and media 10.2; button rows and expandable quotes 10.3) add headings, real tables and footers. The first channels shipped classic HTML because the rendering on real clients was unverified; the owner has since checked Rich Messages on a phone (images, links, bold and italics, code blocks and buttons all render). Discord's documentation states that a webhook message with `IS_COMPONENTS_V2` may carry only components: `content`, `embeds` and `files[n]` fail with 400, so a V2 webhook message cannot upload a screenshot. + +**Decision.** +- Telegram channels send **Rich Messages**: `sendRichMessage` with `rich_message.html` (headings, paragraphs, a bordered table for tables, a compact table for fields, an expandable blockquote, `pre`/`code`, `footer`, `tg-time`), the screenshot as a media block (`` with `media: [{id, media: attach://shot}]`, multipart), `skip_entity_detection: true` so page text never becomes a mention or a command, and an inline keyboard for links and act buttons (`style` success/danger/primary). Revisions use `editMessageText` with `rich_message`, re-using the photo by its `file_id`. The capabilities declare `tables: true`. +- **Classic HTML is the fallback only**: when Telegram rejects a rich call (400, or 404 from a Bot API server that does not know the method), the same delivery is sent as the classic `sendMessage`/`sendPhoto` with `parse_mode: HTML` (tables written as lines), and a 404 makes the channel classic for the rest of the run. Each message remembers its format in its ref, so messages sent classic (including every message from before N2) keep being edited classic. +- Discord sends one embed (colour by severity, fields, image as `attachment://`) plus action rows (link buttons, and interactive buttons in bot mode), not Components V2, in both modes. + +**Consequences.** Tables arrive as tables on Telegram. A formatting mistake costs one extra call, not a lost message. The contract needed no change. + +**Alternatives considered.** *Classic HTML only*: no tables, no headings. *Rich `blocks` (JSON) instead of `html`*: the same result with a larger, less readable payload and preview. *Discord Components V2*: no screenshots through a webhook. + +## D-41 Act buttons: single-use command tokens, allow-lists, presses over outbound connections + +**Status:** Accepted + +**Implementation:** N2 (Telegram, Discord bot mode, ntfy; the generic webhook carries act actions as-is). + +**Context.** An attention request or a vault confirmation waits for a person who may be away from the dashboard. Answering from the chat is the fastest path, but a button in a chat is a remote control for the agent's browser: it must work only for the right person, once, while the request is still open, and it must not need a public endpoint (D-33). + +**Decision.** +- **Opt-in per channel.** `rules.act_buttons` is off by default. With it on, a platform that can receive presses keeps the `act` actions (`degrade` otherwise turns them into their `open` fallback): Telegram, Discord in bot mode, ntfy with a reply topic (D-42). The generic webhook, with act buttons on, carries the `act` actions of the contract unchanged and without tokens; its consumer answers through the REST API with its own bearer token (`POST /api/v1/attention/{request_id}/resolve`, scope `attention:resolve`), so BrowserHive adds no callback endpoint. +- **Command tokens.** Each act button carries `bh1:`: 11 URL-safe characters (66 random bits) minted by the outbox just before the platform call and written first, so an early press finds its row. `notification_action_tokens` binds the token to its channel, notification, action, `op` and `args`, and stores it only as a SHA-256 hash, like API tokens and grants. A token is single use (claimed atomically), expires after 24 h (Telegram keeps undelivered presses that long), and is reused across the edits of a message while it is unused, so a message keeps stable buttons. +- **Checks, in order.** The token is known; it belongs to the channel the press came from, and the press came from that channel's chat (Telegram chat id, Discord channel id); act buttons are still on and the channel is active; the token is unused and unexpired; the notification is still `open`; the presser's platform user id is on the channel's **allow-list** (`rules.allow_list`; by default the person who connected the chat in the setup: the Telegram `/start`, or the Discord "This is me" button; more ids can be added). ntfy has no per-user identity (D-42). Then the command runs through the same application service as the dashboard route: `attention.resolve` (resolve or reject), `vault.confirm.resolve` (approve or deny), `session.close` (terminate). `session.extend_lease` has no operator-side service and is refused. +- **Actor and audit.** The actor is `telegram:`, `discord:` or `ntfy:topic-b`; it is the request's `resolved_by`, so the revised message says who answered and from where. Every press of a known token writes one `notification_actions` row (audit class): when, channel, notification, action, op, actor and display name, outcome (`done`, `failed`, `not_allowed`, `used`, `expired`, `stale`, `wrong_channel`, `disabled`) and detail. A press with an unknown token is answered and counted, not stored, so a stranger cannot fill the audit table. The presser gets a short answer in the chat (a Telegram toast, an ephemeral Discord reply); a refusal names the reason, and a refused allow-list check names the presser's id so the operator can add it. +- **The message follows the state.** A successful command settles the request; the settlement is a revision (03 §9.1) and the outbox's silent edit removes every button and shows the outcome and the actor. A press made while BrowserHive was stopped is processed at the next start if the platform kept it (Telegram 24 h, ntfy's cache 12 h) and refused as `stale` or `expired` when the request no longer waits (the startup reconcile settles orphaned requests first). +- **Scopes.** A press acts with the authority of the operator who enabled act buttons (the single operator holds every scope, D-09); each op is tied to the scope of its dashboard route (`attention:resolve`, `vault:confirm`, `sessions:write`), which the audit records. +- **Presses arrive only over outbound connections**: Telegram `getUpdates` long polling (one poller per bot token, shared with the setup's `/start` wait, its offset persisted so a press is handled once), the Discord gateway (D-38), an ntfy subscription (D-42). Their connection state shows on the channel card. + +**Consequences.** Tokens never appear in logs, the delivery log, previews (which show `bh1:preview-`) or the API. Deleting a channel deletes its tokens; its audit rows keep the channel's name. `notification_actions` follows `auditRetentionDays`; used or expired tokens are pruned a day after they expire. + +**Alternatives considered.** *An HMAC-keyed token* (the plan's first wording): with 66 random bits, a 24-hour life and single use, a keyed hash protects nothing a plain SHA-256 does not, and it adds a key to generate, store and back up. *A signed URL on the dashboard* for the buttons to call: needs a public URL and exposes a callback to the internet (D-33). *Telegram webhooks*: a public HTTPS endpoint. *Act buttons on by default*: a chat is shared more casually than a dashboard login. + +## D-42 ntfy answers through a second, private topic **Status:** Accepted -**Implementation:** the first external channels (N1). +**Implementation:** N2, after a spike (2026-09-28) against `binwiederhier/ntfy:v2.28.0` and ntfy.sh. -**Context.** The plan preferred Telegram's Rich Messages (`sendRichMessage`, Bot API 10.1, June 2026) and left Discord's Components V2 against embeds to a spike. Rich Messages add headings, tables and footers, but they are three months old, their rendering on older Telegram clients is unverified, and no real bot was available to the first channels' build to check them on phones. Discord's documentation states that a webhook message with `IS_COMPONENTS_V2` may carry only components: `content`, `embeds` and `files[n]` fail with 400, so a V2 webhook message cannot upload a screenshot. +**Context.** ntfy has no bot identity and no callbacks; its `http` action makes the phone send a request when a button is tapped. The plan proposed pointing that request at ntfy itself. The spike showed: a notification whose `http` actions target the same server's topic B is accepted unchanged; a streaming subscription to topic B receives a phone-style `POST` within a second; `?since=` returns only later messages; a notification is replaced by its `sequence_id`. ntfy documents the `http` action as supported on Android and iOS (iOS since app 1.1, May 2022). **Decision.** -- Telegram channels send classic messages: `sendMessage`/`sendPhoto` with `parse_mode: HTML` (escaping only `<`, `>`, `&`), an inline keyboard for links, `editMessageText`/`editMessageCaption` for revisions. `degrade` turns tables into lists for this renderer (`tables: false`); the renderer draws headings, fields and quotes itself (an expandable blockquote). Rich Messages stay a later, opt-in renderer once they are verified on real clients; the contract needs no change for it. -- Discord webhooks send one embed (colour by severity, fields, image as `attachment://`) plus an action row of link buttons (`with_components=true`), not Components V2. +- An ntfy channel may name a **reply topic** ("topic B": a literal `reply_topic`, or one from a variable) on the same server, and an optional `reply_token` variable to read it (default: the channel's `token`). With act buttons on, each act button becomes an `http` action that POSTs `bh1:` to `/` and clears the notification; ntfy allows three actions, filled by importance (act buttons first, then links). +- BrowserHive subscribes to topic B (`GET //json`, streaming, outbound), resumes after a restart or a dropped connection from the last message id it handled (`since=`; ntfy's cache keeps 12 h), ignores anything that is not `bh1:`, and answers a valid press like any other (D-41). The resolution replaces the topic-A notification by sequence id (a silent revision). A refused press changes nothing on the phone. +- ntfy has no per-user identity: the actor is `ntfy:topic-b` and there is no allow-list. **Whoever can read topic A can press its buttons**, so topic A must be private: an unguessable name on ntfy.sh, or access control on a self-hosted server. Topic B only needs to be writable by the phone: on a self-hosted server the recommended ACL is `everyone` write-only on topic B, with BrowserHive reading it with a token. Someone who learns only topic B can post to it (ignored unless it is a live token), replay used tokens (refused) and see tokens that were already used; they cannot guess a live token (66 bits) and so cannot act. -**Consequences.** Every Telegram client renders the messages; tables arrive as lists. The Telegram renderer is swappable per channel later without touching producers or the outbox. +**Consequences.** Two-way ntfy needs no BrowserHive endpoint. Presses made while BrowserHive was stopped for more than ntfy's cache time are lost (the request is settled by then anyway). -**Alternatives considered.** *Rich Messages first*: better structure, unverifiable here, and a formatting mistake would fail every send with a 400. *Discord Components V2*: no screenshots through a webhook. +**Alternatives considered.** *Open links only on ntfy*: the fallback if the spike had failed. *Putting an access token in the `http` action's headers*: anyone who reads topic A would get a write token. diff --git a/specs/02-mcp-and-tools.md b/specs/02-mcp-and-tools.md index 671310e..50031b9 100644 --- a/specs/02-mcp-and-tools.md +++ b/specs/02-mcp-and-tools.md @@ -367,4 +367,4 @@ await server.stop({ deadlineMs?: 20_000 }); // idempotent; unwinds even after a - `launch_options.chromiumSandbox`: `false` is refused (`UNSAFE_LAUNCH_ARG`, spec 11 §4); `true` is accepted in every `sandbox` mode because it only strengthens the posture, and makes the sandbox a requirement for that session (a host that cannot give it answers `SANDBOX_UNAVAILABLE`, never `INTERNAL_ERROR`). - `list_saved_auths` scopes by the `owner` field in `.meta.json`; a manifest without it (for example one written by hand) is treated as owned by `local`. - `instructions` on the server and `title` on tools are additive; the golden generator normalizes key order so an SDK reordering does not produce a false diff. -- There is no tool that sends a notification, and none will be added to the core catalog: notifications derive only from facts BrowserHive observed (D-32). `request_attention` is the agent's way to reach a human, and it already produces the `attention.requested` notification (03 §9) that external channels deliver. +- `resolved_by` of an attention outcome names who answered: the operator principal from the dashboard or the API, or, when the operator answered with an act button in a chat (03 §9.6, D-41), the platform alone (`telegram`, `discord`, `ntfy`): the operator's views and the audit keep the full actor (`telegram:`), but the agent is untrusted (D-09) and never learns the operator's chat identity. There is no tool that sends a notification, and none will be added to the core catalog: notifications derive only from facts BrowserHive observed (D-32). `request_attention` is the agent's way to reach a human, and it already produces the `attention.requested` notification (03 §9) that external channels deliver. diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index 0f820de..a1d784b 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -291,16 +291,16 @@ One broker (D-15) backs two resource views; paths stay recognizable. | PUT | `/me/preferences` | S (`preferences:write`) | `{preferences}` (≤ 64 KiB, zod-validated known keys, unknown keys rejected) | `{ok:true, updated_at}` | | GET | `/search` | S | `q` (≥ 2 chars), `limit` ≤ 20 | `{sessions:[{session_id, slug}], tools:[name], vault_handles:[handle], patterns:[pattern]}` — command-palette entity search | -### 4.8.1 Notification channels (D-33, D-37, D-38, D-39; §9.5) +### 4.8.1 Notification channels (D-33, D-37, D-38, D-39, D-41, D-42; §9.5, §9.6) -`ChannelView` = `channel_id, name, kind, mode, source ('db'|'startup'), status ('active'|'paused'|'broken'), target (non-secret coordinates, per kind §9.5), target_hint (a short, lossy rendering for lists: "chat …3456", "ntfy.sh/bh-…", "discord webhook"), secret_refs ({param: ENV_NAME}), secrets ([{param, env, set}]: whether each named variable is set, never its value), rules (NotificationChannelRules), capabilities, ready (the adapter could be built), problem (why not: "BH_TG_TOKEN is not set"), failure_count, last_error, last_ok_at, last_failure_at, created_at, updated_at, stats {sent_24h, failed_24h, suppressed_24h, pending, last_delivery_at, last_status}`. No response ever carries a secret value; requests carry only environment variable names (`SecretEnvName`: not `BROWSERHIVE_*`), and a body that looks like it holds a secret value where a name belongs is a 400 `VALIDATION_FAILED` that never echoes it. +`ChannelView` = `channel_id, name, kind, mode, source ('db'|'startup'), status ('active'|'paused'|'broken'), target (non-secret coordinates, per kind §9.5), target_hint (a short, lossy rendering for lists: "chat …3456", "ntfy.sh/bh-…", "discord webhook"), secret_refs ({param: ENV_NAME}), secrets ([{param, env, set}]: whether each named variable is set, never its value), rules (NotificationChannelRules), capabilities, ready (the adapter could be built), problem (why not: "BH_TG_TOKEN is not set"), failure_count, last_error, last_ok_at, last_failure_at, created_at, updated_at, stats {sent_24h, failed_24h, suppressed_24h, pending, last_delivery_at, last_status}, connection` — `connection` is the state of the channel's press listener (§9.6: `{state: 'connecting'|'connected'|'reconnecting'|'offline', since, detail}`), `null` when the channel receives no presses (act buttons off, or a platform without them). No response ever carries a secret value; requests carry only environment variable names (`SecretEnvName`: not `BROWSERHIVE_*`), and a body that looks like it holds a secret value where a name belongs is a 400 `VALIDATION_FAILED` that never echoes it. | Method | Path | Auth | Request | Response | Errors | |---|---|---|---|---|---| | GET | `/channels` | S (`channels:read`) | — | `{data: ChannelView[], now}` (dashboard channels and startup channels, by name) | — | -| POST | `/channels` | S (`channels:write`) | `ChannelInput {name, kind, mode?, target, secret_refs, rules?}` (kinds `telegram`, `discord` (mode `webhook`), `ntfy`, `webhook`) | 201 `{channel: ChannelView}` | 400 `VALIDATION_FAILED` (a Telegram TTL above 47 h, an unknown target key, a missing required secret), 409 `CHANNEL_NAME_TAKEN`, 400 `CHANNEL_KIND_UNAVAILABLE` (`discord` mode `bot`, and the reserved platforms, until they ship) | +| POST | `/channels` | S (`channels:write`) | `ChannelInput {name, kind, mode?, target, secret_refs, rules?}` (kinds `telegram`, `discord` (mode `webhook` or `bot`), `ntfy`, `webhook`) | 201 `{channel: ChannelView}` | 400 `VALIDATION_FAILED` (a Telegram TTL above 47 h, an unknown target key, a missing required secret, a secret of the other Discord mode, act buttons on Discord webhook mode or on ntfy without a reply topic, an allow-list entry that is not a numeric user id, an allow-list on ntfy), 409 `CHANNEL_NAME_TAKEN`, 400 `CHANNEL_KIND_UNAVAILABLE` (the reserved platforms, until they ship) | | GET | `/channels/{channel_id}` | S (`channels:read`) | — | `{channel: ChannelView}` | 404 `CHANNEL_NOT_FOUND` | -| PATCH | `/channels/{channel_id}` | S (`channels:write`) | partial `ChannelInput` (not `kind`) | `{channel}` | 404, 409 `CHANNEL_READ_ONLY` (a startup channel: it is edited with its flag), 409 `CHANNEL_NAME_TAKEN` | +| PATCH | `/channels/{channel_id}` | S (`channels:write`) | partial `ChannelInput` (not `kind`). The allow-list is edited here, as `rules.allow_list` (a full rules object replaces the stored one); switching the Discord `mode` sends the other mode's `secret_refs` and `target` and keeps the rules | `{channel}` | 404, 409 `CHANNEL_READ_ONLY` (a startup channel: it is edited with its flag), 409 `CHANNEL_NAME_TAKEN` | | DELETE | `/channels/{channel_id}` | S (`channels:write`) | — | `{ok:true}` (the channel's delivery log goes with it) | 404, 409 `CHANNEL_READ_ONLY` | | POST | `/channels/{channel_id}/pause` | S (`channels:write`) | — | `{channel}` (pending jobs become `suppressed: channel_paused`; allowed on startup channels, and the pause survives restarts) | 404 | | POST | `/channels/{channel_id}/resume` | S (`channels:write`) | — | `{channel}` (`active`, consecutive failures reset; also how a `broken` channel is retried) | 404 | @@ -310,7 +310,12 @@ One broker (D-15) backs two resource views; paths stay recognizable. | GET | `/channels/deliveries/{seq}` | S (`channels:read`) | — | `{delivery: DeliveryRow, message: NotificationMessage | null}` — the notification's current message as this channel is shown it (content level and degrade applied: the redacted payload) | 404 `DELIVERY_NOT_FOUND` | | GET | `/channels/env` | S (`channels:read`) | `names` (csv of `SecretEnvName`, ≤ 16) | `{vars: [{name, set}]}` — whether each variable is set and non-empty in the server's environment; never a value | 400 | | POST | `/channels/telegram/connect` | S (`channels:write`) | `{token_env, thread?}` | `{connect_id, bot_username, link: 'https://t.me/?start=', group_link: 'https://t.me/?startgroup=', expires_at}` — checks the token with `getMe`, then long-polls `getUpdates` for 2 minutes waiting for `/start ` in a private chat or a group; one connect per token at a time (a new one cancels the old) | 400, 409 `CHANNEL_NOT_READY` (the variable is unset), 502 `CHANNEL_PLATFORM_ERROR` (Telegram refused the token) | -| GET | `/channels/telegram/connect/{connect_id}` | S (`channels:read`) | — | `{status:'waiting'|'connected'|'expired'|'failed', chat?:{id, title, type}, user?:{id, name}, error?}` — `user` becomes the first allow-list entry (N2 act buttons) | 404 | +| GET | `/channels/telegram/connect/{connect_id}` | S (`channels:read`) | — | `{status:'waiting'|'connected'|'expired'|'failed', chat?:{id, title, type}, user?:{id, name}, error?}` — `user` becomes the first allow-list entry (§9.6). The wait shares the bot's update poller when the bot already serves a channel with act buttons (Telegram answers 409 to two concurrent `getUpdates`) | 404 | +| POST | `/channels/discord/bot` | S (`channels:write`) | `{token_env}` | `DiscordBotInfo {application_id, bot_id, bot_username, invite_url, guilds: [{id, name}]}` — checks the bot token (`GET /users/@me`, `/applications/@me`), builds the invite link with the minimal permissions (`52224`: View Channel, Send Messages, Embed Links, Attach Files; scope `bot`) and lists the servers the bot is in | 400, 409 `CHANNEL_NOT_READY` (unset variable), 502 `CHANNEL_PLATFORM_ERROR` | +| POST | `/channels/discord/channels` | S (`channels:write`) | `{token_env, guild_id}` | `{channels: [{id, name, type: 'text'|'announcement', category}]}` — the server's text and announcement channels, by position | 400, 409, 502 | +| POST | `/channels/discord/connect` | S (`channels:write`) | `{token_env, channel_id}` | `{connect_id, expires_at}` — the bot posts a message with a "This is me" button in the channel and waits up to 2 minutes (over the gateway) for the press that names the operator's Discord account; the message is deleted afterwards | 400, 409, 502 | +| GET | `/channels/discord/connect/{connect_id}` | S (`channels:read`) | — | `{status:'waiting'|'connected'|'expired'|'failed', user?:{id, name}, error?, expires_at}` — `user` becomes the first allow-list entry | 404 | +| GET | `/channels/actions` | S (`channels:read`) | filters `channel_id`, `notification_id`, `outcome[]`, cursor (`seq`), `limit` | `Page` newest first — `seq, at, channel_id, channel_name, channel_kind, notification_id, notification_title, action_id, action_label, op, actor ('telegram:', 'discord:', 'ntfy:topic-b'), actor_name, outcome ('done'|'failed'|'not_allowed'|'used'|'expired'|'stale'|'wrong_channel'|'disabled'), detail` (the audit of act-button presses, §9.6; never a token) | — | ### 4.9 Client errors @@ -405,7 +410,7 @@ Ordering: `screencast.start`, `screencast.stop` and `screencast.set_size` from o | `pages` | `page.visited` `{row}` fleet-wide (scope `sessions:read`), so overview and websites views update live; also published on `session:` | | `blocklist` | `blocklist.hit` `{row}`, `blocklist.reloaded` `{patterns, skipped}` | | `system` | `system.degraded` `{event: SystemEvent}`, `system.recovered`, `tick`, `capacity` `{live, max}`, `retention.completed` | -| `channels` | `channel.changed` `{channel: ChannelView}` (created, edited, paused, resumed, broken, or its stats moved), `channel.removed` `{channel_id}`, `delivery.updated` `{delivery: DeliveryRow}` (a job enqueued or its status changed; the live delivery log) — scope `channels:read` | +| `channels` | `channel.changed` `{channel: ChannelView}` (created, edited, paused, resumed, broken, or its stats moved), `channel.removed` `{channel_id}`, `delivery.updated` `{delivery: DeliveryRow}` (a job enqueued or its status changed; the live delivery log), `action.recorded` `{action: ActionRow}` (an act-button press was audited, §9.6) — scope `channels:read`; `channel.changed` also fires when a channel's press listener changes state | | `notifications` | `notification.created` `{notification}` (first occurrence), `notification.updated` `{notification}` (the full row: a group grew — `count`, `title`, `body`, `updated_at`, `source_event_id`, `revision` changed — a lifecycle revision changed `state` and `revision` (`updated_at` unchanged), or it was read/dismissed; clients upsert by `notification_id` and re-position by `updated_at`) | | `logs` | `log.record` `{record}` (droppable, live only) | | `screencast:` | `meta`, `started`, `stopped`, `failed {code}` | @@ -425,7 +430,7 @@ Every event is produced by the app-level event bus (01 §5); the hub only maps b --- -## 7. Data model (schema v5) +## 7. Data model (schema v6) All tables in `browserhive.db` (D-24). Conventions: `TEXT` ids, epoch-ms `INTEGER` columns suffixed `_at`/`_ts`, durations `_ms`, sizes `_bytes`, booleans `INTEGER CHECK IN (0,1)`, enums `TEXT CHECK (col IN (...))` generated from `contracts/enums`, every `session_id` FK `ON DELETE CASCADE`. `WITHOUT ROWID` on tables with a TEXT primary key that are never scanned in insertion order. @@ -600,6 +605,41 @@ CREATE TABLE notification_channel_messages ( -- the platform message e ) WITHOUT ROWID; CREATE INDEX idx_notification_channel_messages_expiry ON notification_channel_messages(expires_at) WHERE expires_at IS NOT NULL AND deleted_at IS NULL; CREATE INDEX idx_notification_channel_messages_thread ON notification_channel_messages(channel_id, thread, sent_at); +-- v6: act buttons (§9.6, D-41, D-42) +CREATE TABLE notification_action_tokens ( -- one command token per act button per channel message; the token itself exists only in the platform message + token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the token (the part after `bh1:`) + channel_id TEXT NOT NULL REFERENCES notification_channels(channel_id) ON DELETE CASCADE, + notification_id TEXT NOT NULL REFERENCES notifications(notification_id) ON DELETE CASCADE, + action_id TEXT NOT NULL, -- the contract action's id (`resolve`, `reject`, `approve`, `deny`) + op TEXT NOT NULL, -- NotificationCommandOp (open set, no CHECK) + args_json TEXT NOT NULL, -- the command's args + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, -- created_at + 24 h + used_at INTEGER -- set once, atomically, by the press that claims it +) WITHOUT ROWID; +CREATE INDEX idx_notification_action_tokens_message ON notification_action_tokens(channel_id, notification_id, action_id); +CREATE INDEX idx_notification_action_tokens_expiry ON notification_action_tokens(expires_at); +CREATE TABLE notification_actions ( -- audit: every press of a known token (unknown tokens are only counted) + seq INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + channel_id TEXT NOT NULL, -- no FK: the audit outlives the channel … + channel_name TEXT NOT NULL, -- … and keeps its name and kind + channel_kind TEXT NOT NULL, + notification_id TEXT, -- no FK: outlives the notification + action_id TEXT NOT NULL, action_label TEXT, op TEXT NOT NULL, args_json TEXT NOT NULL, + actor TEXT NOT NULL, -- telegram: | discord: | ntfy:topic-b + actor_name TEXT, -- the platform's display name, when it gives one + outcome TEXT NOT NULL CHECK (outcome IN ('done','failed','not_allowed','used','expired','stale','wrong_channel','disabled')), + detail TEXT -- the answer shown to the presser, or the failure (scrubbed, ≤ 500) +); +CREATE INDEX idx_notification_actions_at ON notification_actions(at); +CREATE INDEX idx_notification_actions_channel ON notification_actions(channel_id, seq); +CREATE INDEX idx_notification_actions_notification ON notification_actions(notification_id, seq) WHERE notification_id IS NOT NULL; +CREATE TABLE notification_cursors ( -- where each press listener resumes: a Telegram update offset per bot, the last ntfy message id per channel + cursor_key TEXT PRIMARY KEY, -- telegram: | ntfy:; never a token or a topic name + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +) WITHOUT ROWID; -- created in v1 ahead of use: logs (written only by the optional `--logPersist` durable sink), resource_samples (no writer yet; pruned by retention) -- reserved names, NOT created: proxies, profiles, security_rules, extensions — the migration of the feature that needs one creates it (and may pick another name) CREATE TABLE logs (seq INTEGER PRIMARY KEY, ts INTEGER NOT NULL, level TEXT NOT NULL, module TEXT NOT NULL, msg TEXT NOT NULL, trace_id TEXT, span_id TEXT, request_id TEXT, session_id TEXT, principal TEXT, fields_json TEXT); @@ -607,20 +647,21 @@ CREATE INDEX idx_logs_ts ON logs(ts); CREATE INDEX idx_logs_trace ON logs(trace_ CREATE TABLE resource_samples (ts INTEGER NOT NULL, session_id TEXT REFERENCES sessions(session_id) ON DELETE CASCADE, cpu_pct REAL, rss_bytes INTEGER, host_free_bytes INTEGER, PRIMARY KEY (ts, session_id)) WITHOUT ROWID; ``` -`meta.min_reader_version = 1`. Migration v1 (`0001-initial`) creates everything above except the v2 to v5 lines; migration v2 (`0002-notification-groups`, `compatible: true`, so the min reader stays 1) adds the notification grouping columns and indexes; migration v3 (`0003-harness-identity`, `compatible: true`) adds the identity columns and indexes, backfills `workspace` from `agent_name` and the two source columns where a value was set, and leaves `sessions.harness` NULL for existing sessions; migration v4 (`0004-session-browser`, `compatible: true`) adds `sessions.sandboxed` and `sessions.browser_version`, both NULL for existing sessions (no guess is backfilled: under `sandbox=auto` the answer depends on the browser and the host); migration v5 (`0005-notification-outbox`, `compatible: true`) adds the notification contract columns and the three outbox tables. v5 backfills every existing notification from facts only: `kind` from `type` (`attention` → `attention.requested`, `vault` → `vault.confirm`, `lifecycle` → `session.reaped`, `system` → `system.degraded`, `error` titled "Session crashed" → `session.crashed`, any other `error` → `tool.errors`), `category` and `severity` from `kind` (§9), `state` from the operator request or degradation the row points at (`source_event_id`; `open` while pending/unresolved, `resolved`, `expired` on timeout, `final` when cancelled or gone), `open` for tool-error groups and `final` for the rest, `thread` from the ids the row carries, `revision` 1; `message_json` stays NULL. The runner writes a backup before migrating. A dedicated CI test asserts fresh == migrated (D-04); fixtures `v1.db` to `v5.db` upgrade to head, and the golden is `schema-v5.json`. +`meta.min_reader_version = 1`. Migration v1 (`0001-initial`) creates everything above except the v2 to v5 lines; migration v2 (`0002-notification-groups`, `compatible: true`, so the min reader stays 1) adds the notification grouping columns and indexes; migration v3 (`0003-harness-identity`, `compatible: true`) adds the identity columns and indexes, backfills `workspace` from `agent_name` and the two source columns where a value was set, and leaves `sessions.harness` NULL for existing sessions; migration v4 (`0004-session-browser`, `compatible: true`) adds `sessions.sandboxed` and `sessions.browser_version`, both NULL for existing sessions (no guess is backfilled: under `sandbox=auto` the answer depends on the browser and the host); migration v5 (`0005-notification-outbox`, `compatible: true`) adds the notification contract columns and the three outbox tables; migration v6 (`0006-notification-actions`, `compatible: true`) adds `notification_action_tokens`, `notification_actions` and `notification_cursors` (nothing to backfill). v5 backfills every existing notification from facts only: `kind` from `type` (`attention` → `attention.requested`, `vault` → `vault.confirm`, `lifecycle` → `session.reaped`, `system` → `system.degraded`, `error` titled "Session crashed" → `session.crashed`, any other `error` → `tool.errors`), `category` and `severity` from `kind` (§9), `state` from the operator request or degradation the row points at (`source_event_id`; `open` while pending/unresolved, `resolved`, `expired` on timeout, `final` when cancelled or gone), `open` for tool-error groups and `final` for the rest, `thread` from the ids the row carries, `revision` 1; `message_json` stays NULL. The runner writes a backup before migrating. A dedicated CI test asserts fresh == migrated (D-04); fixtures `v1.db` to `v6.db` upgrade to head, and the golden is `schema-v6.json`. ### 7.1 Retention classes | Class | Tables / artifacts | Rule (defaults) | |---|---|---| | telemetry | `events`, `tool_calls`, `pages`, `screenshots` (+ files), `resource_samples`, `logs` | `retentionDays` (7) and `retentionBytes` (1 GiB) over the DB + screenshot files; oldest first | -| audit | `vault_access`, `blocked_requests`, `auth_events`, `operator_actions`, `operator_requests` (terminal) | `auditRetentionDays` (90); never byte-pruned | +| audit | `vault_access`, `blocked_requests`, `auth_events`, `operator_actions`, `operator_requests` (terminal), `notification_actions` | `auditRetentionDays` (90); never byte-pruned | | sessions | `sessions` rows | deleted only when all children are gone and `closed_at < now - retentionDays`; **archived sessions exempt** | | artifacts | `trace.zip`, `sessions//`, downloads | follow their session; deletion via `artifact_outbox` (row delete and outbox insert in one transaction; sweeper unlinks with retries; orphan scan weekly) | | connections | `mcp_connections` (with its IP, `User-Agent` and meta bag) | closed rows with `closed_at < now - retentionDays` that no remaining `sessions` row references (a session keeps its client metadata as long as it lives, archived ones included); open rows never. A pruned row's tool calls are older than it, so they are pruned first; a session's own harness survives in `sessions.harness` | | notifications | `notifications` | 30 d after `dismissed_at`/`read_at`, 90 d otherwise (a pruned row takes its deliveries and channel messages with it) | | notification deliveries | `notification_deliveries` (terminal rows: `sent`, `dead`, `suppressed`, `superseded`), `notification_channel_messages` (deleted, or without a TTL) | telemetry-like, own window: 30 d after `updated_at`; never byte-pruned; `pending`, `sending` and `retrying` jobs and messages with a pending TTL are never pruned | -| configuration | `notification_channels`, `vault_bindings`, `vault_group_policies`, `preferences` | never pruned; `purge` lists them with the other tables (deleting the database loses configured channels and bindings) | +| action tokens | `notification_action_tokens` | pruned one day after `expires_at` (used or not); a channel or notification delete takes its tokens | +| configuration | `notification_channels`, `notification_cursors`, `vault_bindings`, `vault_group_policies`, `preferences` | never pruned; `purge` lists them with the other tables (deleting the database loses configured channels and bindings) | | backups | `backups/*.db` | keep last 5 | `retentionDays` must be ≥ 1 (`0` is rejected at config time, see 08 — "keep forever" is expressed by the per-class exemptions below and a large value). The sweep runs every 6 h (`retentionIntervalMs`), catches per-item failures, records a `system.degraded` on repeated failure, never throws, and never runs `VACUUM`: the DB is opened with `auto_vacuum=INCREMENTAL` and the sweep issues `PRAGMA incremental_vacuum(N)` in bounded chunks. `/system.retention` exposes the last run. @@ -643,6 +684,9 @@ interface NotificationRepository { …; list(query /* sort: updated_at|created_a interface NotificationChannelRepository { list(); get(id); getByName(name); upsert(row); remove(id); setStatus(id, status, at); recordSuccess(id, at); recordFailure(id, at, error): {failureCount}; } interface NotificationDeliveryRepository { enqueue(rows) /* ignores duplicates of (channel, notification, revision, op) */; due(now, limit); claim(seq, at): boolean /* pending|retrying → sending */; finish(seq, patch); supersedeOlder(channelId, notificationId, revision, at); recoverSending(at): number; pendingByChannel(channelId, severity); list(query); get(seq); } interface NotificationChannelMessageRepository { get(channelId, notificationId); upsert(row); firstInThread(channelId, thread); dueForDelete(now, limit) /* expired, not deleted, no delete job yet */; markDeleted(channelId, notificationId, at); } +interface NotificationActionTokenRepository { insert(rows); reusable(channelId, notificationId, now) /* unused, unexpired, per action id */; get(tokenHash); claim(tokenHash, at): boolean /* used_at NULL → at, once */; prune(before): number; } +interface NotificationActionRepository { insert(row): seq; list(query /* channelId, notificationId, outcomes, beforeSeq, limit; newest first */); get(seq); prune(before): number; } +interface NotificationCursorRepository { get(key); set(key, value, at); remove(key); } interface PreferenceRepository / SystemEventRepository / IdempotencyRepository / ArtifactOutboxRepository interface McpConnectionRepository { insert(row); update(id, patch); get(id); listOpen(); listRecent(limit) /* live first, with session counts */; closeAll(at); } interface UnitOfWork { transaction(fn: (repos: Repositories) => Promise): Promise; } @@ -705,7 +749,7 @@ Rows whose classification columns are NULL (written by an older reader in the co ### 9.3 Channels and the registry -`NotificationChannel` (`ports/notification-channel.ts`) is the platform seam: `id`, `name`, `kind`, `capabilities` (rich blocks, tables, images, act buttons, open links, edit, delete, replies, delete window, max title/text length, max buttons), `send(delivery) → {ref}`, `edit(ref, delivery) → {ref}`, `delete(ref)`. A delivery is the restricted, degraded message plus the `LinkBuilder` and, where the platform supports replies, the ref of the first message of the thread. A platform failure is a `ChannelSendError` (`retryable`, `retryAfterMs`, `code`: `rate_limited`, `unavailable`, `timeout`, `auth`, `rejected`, `message_gone`, `too_old`). The in-app channel (`kind: in-app`) implements the same port and is delivered **inline after the commit**: the row is the delivery, so it has no outbox rows. +`NotificationChannel` (`ports/notification-channel.ts`) is the platform seam: `id`, `name`, `kind`, `capabilities` (rich blocks, tables, images, act buttons, open links, edit, delete, replies, delete window, max title/text length, max buttons), `send(delivery) → {ref}`, `edit(ref, delivery) → {ref}`, `delete(ref)`, and optionally `presses` (a press listener, §9.6). A delivery is the restricted, degraded message plus the `LinkBuilder`, where the platform supports replies the ref of the first message of the thread, and, where act buttons are on, `actTokens` (action id → `bh1:`, minted by the outbox just before the call). The capabilities of a channel depend on its setup (mode, target, secret names, rules): `actButtons` is true only where presses can arrive and `rules.act_buttons` is on (§9.6). A platform failure is a `ChannelSendError` (`retryable`, `retryAfterMs`, `code`: `rate_limited`, `unavailable`, `timeout`, `auth`, `rejected`, `message_gone`, `too_old`). The in-app channel (`kind: in-app`) implements the same port and is delivered **inline after the commit**: the row is the delivery, so it has no outbox rows. `ChannelRegistry` (`app/notifications/channel-registry.ts`) holds the configured channels (`notification_channels`) and builds an adapter for each through factories registered per kind by composition (`telegram`, `discord`, `ntfy` and `webhook` since N1, §9.5). A channel whose adapter cannot be built (no factory for its kind, or a secret variable that is unset) keeps its row, reports why as `problem` in the API, and its jobs are suppressed with reason `no_adapter`. At start it projects the startup channels (`--notificationChannel`, 08 §5.7, D-39) into rows with `source = 'startup'`: configuration columns rewritten, status and failure counters kept, rows no longer declared removed; a name that a `source = 'db'` channel already uses stops startup with `CONFIG_INVALID` (exit 64). Channel rows store environment variable names only (D-33). @@ -720,27 +764,45 @@ Rows whose classification columns are NULL (written by an older reader in the co - **Breaker.** A success resets the channel's consecutive `failure_count` and sets `last_ok_at`; a failure increments it and records `last_error`. At 5 the channel becomes `broken`, its pending jobs are `suppressed: channel_paused`, `notification.channel.changed` is published and the `channel.broken` in-app notification is produced. No `system.degraded` is ever raised for a channel. Resuming a channel through the channel API sets it `active` and resets the count. - **Backlog collapse.** When more than 20 `info` sends are pending for one channel, the newest is sent with a "you missed N" footer and the others are `superseded: collapsed`. - **Telemetry.** `browserhive.notifications.deliveries{channel_kind,status}` counts each finished job; each platform call is a `notification.deliver` span (10 §6, §7). -### 9.5 Platforms (N1: Telegram, Discord webhook, ntfy, generic webhook) +### 9.5 Platforms (Telegram, Discord webhook and bot, ntfy, generic webhook) Adapters live in `core/src/infra/notifications/` (the only code that calls a platform; `fetch` only there). Each is a **pure renderer** (`NotificationMessage` + links + capabilities → the platform request) plus a **transport** (the call, the error classification of §9.3 and the returned ref). Composition registers one factory per kind; the renderers are also registered for `POST /channels/preview`, so a preview is byte-for-byte the request a send makes. Shared: one HTTP helper (timeout 10 s, `Retry-After`/`retry_after` → `rate_limited` with `retryAfterMs`, 5xx/network → `unavailable`/`timeout`, 401/403 → `auth`, 404 on an edit or delete → `message_gone`, other 4xx → `rejected`; every URL and error text passes the `Redactor`, so a token in a URL path never reaches a log or `last_error`). -| | Telegram (bot) | Discord (webhook mode) | ntfy | Generic webhook | +| | Telegram (bot) | Discord (webhook or bot mode) | ntfy | Generic webhook | |---|---|---|---|---| -| Channel config | target `{chat_id, thread_id?, chat_title?, bot_username?}`; secret `token` | secret `webhook` (the webhook URL); mode `webhook` | target `{server (default https://ntfy.sh), topic?}`; secrets `token?`, `topic?` (a topic from a variable) | target `{url?}`; secrets `url?` (a URL from a variable), `secret?` (HMAC key) | -| Message | `sendMessage` with `parse_mode: HTML` (only `<`, `>`, `&` escaped; `b`, `i`, `code`, `pre`, `blockquote expandable`), ≤ 4096 characters; with an image `sendPhoto` (multipart, JPEG) and the text as the caption, ≤ 1024 | `POST ?wait=true&with_components=true`: one embed (title, description, fields inline, colour by severity, footer, timestamp) and an action row of link buttons; with an image `multipart` `payload_json` + `files[0]`, shown as the embed image `attachment://…` | `POST /` JSON `{topic, title, message, priority, tags, click, actions, markdown:false}`; with an image `PUT /` with the JPEG as the body and the fields as query parameters | `POST ` with the envelope `{schema, event:'notification', delivered_at, channel:{id,name}, links:{open}, message}` (the contract itself) | -| Links | inline keyboard URL buttons (2 per row; an edit sends an empty keyboard to remove them) | link buttons (style 5) | `view` actions (≤ 3) and `click` | `links` map of absolute URLs | -| Edit | `editMessageText` / `editMessageCaption`, with the keyboard (`reply_markup`) | `PATCH …/messages/{id}?with_components=true` keeping the attachment (`attachments: [{id}]`) | re-publish with the same `sequence_id` (`X-Sequence-ID`) | re-POST with the higher `revision` (consumers keep the highest) | -| Delete | `deleteMessage`, only < 48 h after sending (`deleteWindowMs` 48 h; the setup caps TTLs at 47 h; "message can't be deleted" → `too_old`) | `DELETE …/messages/{id}` | `DELETE //` | n/a (`delete: false`) | +| Channel config | target `{chat_id, thread_id?, chat_title?, bot_username?}`; secret `token` | webhook mode: secret `webhook` (the webhook URL); bot mode: secret `token` (the bot token), target `{channel_id, guild_id?, channel_name?, guild_name?}` | target `{server (default https://ntfy.sh), topic?, reply_topic?}`; secrets `token?`, `topic?` (a topic from a variable), `reply_topic?`, `reply_token?` | target `{url?}`; secrets `url?` (a URL from a variable), `secret?` (HMAC key) | +| Message | a Rich Message (D-40): `sendRichMessage` with `rich_message {html, skip_entity_detection: true}` — the title as `

`, the summary as `

`, fields as a compact table, tables as a bordered table, quotes as `

`, code as `
`, the footer as `