diff --git a/config.example.yaml b/config.example.yaml index 84fac8f3f..41b2690b6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -270,6 +270,14 @@ slack: # agent_id: 00000000-0000-0000-0000-000000000000 # max_streams: 8 +# The email channel, hosted mode only (needs the channels.hosted settings +# above): the gateway delivers the mail that members of the agent send to its +# address, and each mail starts or continues a session. Nerve posts no answer; +# the agent answers with the reply tool that the gateway names. A reload +# switches it on or off. See docs/config.md#email. +# email: +# enabled: false + # Where notify, ask_user, and propose_action deliver. The list replaces the # default rather than adding to it, so name every transport you want. The # panel (web) is always in it. A transport that is off costs nothing here. diff --git a/docs/architecture.md b/docs/architecture.md index fc94dde27..161934071 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,7 @@ Abstract communication layer with three components: Implementations: - **Telegram** — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions, `/reply` command for free-text answers; a reply to a question card answers it too. With `telegram.reply_routes_to_origin_session`, a reply routes to the session that produced the replied-to message; the message→session map is the `channel_reply_routes` table (recorded whether or not the flag is on, pruned after 90 days by the maintenance tick), so it survives restarts - **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. `SlackAccessPolicy` (`slack_access.py`) composes user, channel, and DM guardrails; in channels the bot answers only on mention or in a thread it already owns. -- **Hosted** (`hosted/`): provider traffic that a shared channel gateway holds (`slack.mode: hosted`). Nerve has no provider tokens. Each gateway replica opens a WebSocket to `/_internal/channel/v1/stream`, authenticated with a token that the gateway signs (ES256, issuer `nerve-gateway`, audience `nerve-channel`, subject naming this exact tenant and agent, at most 300 seconds of lifetime). One inbox reader pulls admitted events over the preferred stream: read, process, acknowledge, and read again until a page is empty. The runtime builds one `HostedChannel` for each provider in the table of hosted chat providers (`hosted/providers.py`), which gives each provider its hosted mode, its switch, and its channel settings, with defaults for a provider whose config has none; Slack is the only entry. `HostedChannel` registers under the provider's name, so the router and tools see `slack` as with Socket Mode, and uses the same session keys. When the gateway names the provider in a connection's capabilities, a channel uses only the connections of its own provider. The connection's behaviour can tell the router to post nothing for a turn (`InboundMessage.post_answer`), so that the agent answers only with a tool, and can keep a conversation in its last session past the sticky period. The gateway decides which events invoke the agent: a direct message, a mention of the agent, a thread reply, a reaction, an interaction, or a command. A mention of the agent carries the `self` flag, so intake needs no capabilities. Each invoke event names the person that the gateway resolved from the provider user; in external mode, the router resolves it to a human actor (`ChannelRouter.actor_for_principal`), and the turn runs as that person. An observe event can name the person that the gateway linked to its author; the inbox record shows that person for attribution only, and no actor comes from it. Outbound messages are contract operations: `OperationRunner` (`hosted/operations.py`) sends each on a stream that advertised it for the connection, within the stream and connection in-flight limits, and never sends a side-effecting operation again after its result is lost. File uploads and attachment reads use transfers on the stream of their operation; a read is sent only while its length fits Nerve's stream memory limit. Notification cards carry buttons, and a press arrives as an `interaction` event that answers the notification. A card goes with `notify`, and the gateway posts it in the notification channel that the control plane set for the agent; the config does not choose this conversation. `hosted/contract/` holds the version 1 channel contract frames that Nerve reads and sends. The gateway checks the contract; Nerve checks only the members and values that its code uses, and its size limits. +- **Hosted** (`hosted/`): provider traffic that a shared channel gateway holds (`slack.mode: hosted`). Nerve has no provider tokens. Each gateway replica opens a WebSocket to `/_internal/channel/v1/stream`, authenticated with a token that the gateway signs (ES256, issuer `nerve-gateway`, audience `nerve-channel`, subject naming this exact tenant and agent, at most 300 seconds of lifetime). One inbox reader pulls admitted events over the preferred stream: read, process, acknowledge, and read again until a page is empty. The runtime builds one `HostedChannel` for each provider in the table of hosted chat providers (`hosted/providers.py`), which gives each provider its hosted mode, its switch, and its channel settings, with defaults for a provider whose config has none. The entries are Slack and email: usable `channels.hosted` settings give the email channel, `email.enabled` switches it per event, and the subscriptions frame names `email` while it is on, because the gateway takes member mail only for a subscribed agent. `HostedChannel` registers under the provider's name, so the router and tools see `slack` as with Socket Mode, and uses the same session keys. When the gateway names the provider in a connection's capabilities, a channel uses only the connections of its own provider. The connection's behaviour can tell the router to post nothing for a turn (`InboundMessage.post_answer`), so that the agent answers only with a tool, and can keep a conversation in its last session past the sticky period. The gateway decides which events invoke the agent: a direct message, a mention of the agent, a thread reply, a reaction, an interaction, or a command. A mention of the agent carries the `self` flag, so intake needs no capabilities. Each invoke event names the person that the gateway resolved from the provider user; in external mode, the router resolves it to a human actor (`ChannelRouter.actor_for_principal`), and the turn runs as that person. An observe event can name the person that the gateway linked to its author; the inbox record shows that person for attribution only, and no actor comes from it. Outbound messages are contract operations: `OperationRunner` (`hosted/operations.py`) sends each on a stream that advertised it for the connection, within the stream and connection in-flight limits, and never sends a side-effecting operation again after its result is lost. File uploads and attachment reads use transfers on the stream of their operation; a read is sent only while its length fits Nerve's stream memory limit. Notification cards carry buttons, and a press arrives as an `interaction` event that answers the notification. A card goes with `notify`, and the gateway posts it in the notification channel that the control plane set for the agent; the config does not choose this conversation. `hosted/contract/` holds the version 1 channel contract frames that Nerve reads and sends. The gateway checks the contract; Nerve checks only the members and values that its code uses, and its size limits. - **Web** — Passive channel using gateway WebSocket Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic. diff --git a/docs/config.md b/docs/config.md index 48ba29800..cf3451186 100644 --- a/docs/config.md +++ b/docs/config.md @@ -257,6 +257,7 @@ A reload is always explicit. Two things cause one: | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode`, `.reply_routes_to_origin_session` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; toggling `reply_routes_to_origin_session` takes effect on the next message, and applies to replies to earlier messages too (routes are recorded while it is off); `allowed_users` does not follow it (see the restart table) | | `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event. In hosted mode `enabled: false` pauses intake, the records of a hosted GitHub source and the events of the other hosted providers included, and notifications until it is `true` again, `source.*` applies to the next event, and `mode` needs a restart (see the restart table) | +| `email.enabled` | ✅ read per event. The reload publishes the subscriptions to the gateway again, which then takes or refuses new mail. When no stream sends them, the reload reports `subscriptions` with a "not sent yet" note, which is not an error: the next stream that connects sends them before Nerve reads from it. The email channel must exist: usable `channels.hosted` settings at startup give it. Without usable settings the reload repeats the startup warning under `email`; with usable settings and no email channel it says that a restart is needed (see [Email](#email)) | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`, `codex.models`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, `agent.subagents.*`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.base_url`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -295,7 +296,7 @@ reload cannot inspect, and are documented here only. | `telegram.enabled`, `.bot_token`, `.allowed_users` | the bot was built with that token, and the allow-list was copied into a set when it was built. Notification *delivery* does follow a reload, so after changing `allowed_users` the two can disagree until a restart. `dm_policy` and `stream_mode` are read per update and do follow a reload (see the table above) | | `mcp_endpoint.*` | fixed when the app was created | | `mcp_gateway_url` | the catalog reader is built at startup. See [MCP servers in external mode](#mcp-servers-in-external-mode) | -| `slack.mode`, `channels.hosted.*` | the hosted channel, its stream endpoint, and its token verifier are built at startup. A reload reads the file that `gateway_jwks_file` names again, but not a changed path. Changing `mode` to `socket` reports a Slack reload error until a restart, because the hosted channel still holds the `slack` name. Changing it to `hosted` stops the Socket Mode connection at once, and the stream endpoint opens after a restart | +| `slack.mode`, `channels.hosted.*` | the hosted channels, the email channel included, their stream endpoint, and their token verifier are built at startup. A reload reads the file that `gateway_jwks_file` names again, but not a changed path. Changing `mode` to `socket` reports a Slack reload error until a restart, because the hosted channel still holds the `slack` name. Changing it to `hosted` stops the Socket Mode connection at once, and the stream endpoint opens after a restart | | `sync.github.mode` | the GitHub sources (`github` and the repository watch, `github_repos`), and in hosted mode the stream endpoint and the consumers of the gateway's records, are chosen at startup. Until a restart the sources keep the mode they started with, and so does the `github_pr_activity` gate. The filters, `pr_watch`, `inbox` and `sync.github_repos` do follow a reload: in hosted mode the reload publishes them to the gateway again. To go back to `poll`, set `enabled: false` (and `sync.github_repos.enabled: false`) and reload first (see [sources.md](sources.md#hosted-github-source)) | | `auth.jwt_secret` | pinned at startup for every consumer, web gateway and MCP endpoint alike. A reload that changes or removes it is reported and changes nothing live: removing the key must not reopen the instance, and rotating it must not swap the key under live sessions half-way. The next restart applies it (with the key removed, the secret generated into `nerve.db` takes over) | | `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach | @@ -1456,24 +1457,28 @@ notifications, and with `sync.github_repos` the repositories it watches; the `channels.hosted` settings above apply unchanged, and they are enough on their own: Slack need not be hosted too. See [Hosted GitHub source](sources.md#hosted-github-source) and -[Hosted repository watch](sources.md#hosted-repository-watch). - -When Slack is hosted as well, its switch also holds GitHub back. Nerve reads -the events of every hosted provider from one ordered inbox, and it reads -nothing while hosted Slack is switched off (`slack.enabled: false`): the -GitHub records wait at the gateway until Slack is switched on again, and past -the gateway's limit of stored records the oldest are dropped. Keep a hosted -Slack switched on while GitHub is hosted; `nerve doctor` warns when it is off. -This is a known limitation: a read for each provider on its own, which would -keep GitHub records arriving while hosted Slack is off, is a follow-up. +[Hosted repository watch](sources.md#hosted-repository-watch). They also +carry the mail that members send to the agent: the usable `channels.hosted` +settings give the email channel, which `email.enabled` switches on (see +[Email](#email)). + +When Slack is hosted as well, its switch also holds GitHub and member mail +back. Nerve reads the events of every hosted provider from one ordered +inbox, and it reads nothing while hosted Slack is switched off +(`slack.enabled: false`): the GitHub records wait at the gateway until Slack +is switched on again, and past the gateway's limit of stored records the +oldest are dropped. Keep a hosted Slack switched on while GitHub is hosted; +`nerve doctor` warns when it is off. This is a known limitation: a read for +each provider on its own, which would keep GitHub records arriving while +hosted Slack is off, is a follow-up. #### Connection behaviour -Nerve builds one hosted channel for each chat provider in hosted mode; Slack -is the only hosted chat provider of this version. The capabilities that the -gateway sends for a connection can name the connection's provider and a -behaviour, which tells Nerve how to answer the turns of its events and which -session they go to: +Nerve builds one hosted channel for each chat provider in hosted mode. The +hosted chat providers of this version are Slack and email (see +[Email](#email)). The capabilities that the gateway sends for a connection +can name the connection's provider and a behaviour, which tells Nerve how to +answer the turns of its events and which session they go to: | Member | Values | Default | Effect | |--------|--------|---------|--------| @@ -1482,21 +1487,24 @@ session they go to: | `session` | `rotate`, `keep` | `rotate` | `rotate`: a conversation gets a new session after `sessions.sticky_period_minutes` of idle time. `keep`: a conversation continues its last session, however long it was idle, until that session is archived or deleted | Slack connections post and rotate, so a gateway that sends no behaviour -changes nothing. A connection whose capabilities Nerve does not have posts -and rotates too. In `tool` mode, Nerve puts one line before the text of the -turn: "This message came through *provider*. Your answer in this session is -not sent to anyone. To answer, use the *reply tool* tool." Without a reply -tool, the line ends with "This channel does not send answers." +changes nothing. The email connection answers with `tool`, names the reply +tool, and keeps its sessions. A connection whose capabilities Nerve does not +have posts and rotates too. In `tool` mode, Nerve puts one line before the +text of the turn: "This message came through *provider*. Your answer in this +session is not sent to anyone. To answer, use the *reply tool* tool." +Without a reply tool, the line ends with "This channel does not send +answers." Each hosted chat provider has its own switch. Because the gateway keeps the events of every provider in one ordered inbox, a provider that is switched off either holds the events of all providers or refuses its own events. Hosted Slack holds them: while `slack.enabled` is `false`, Nerve reads nothing, and the events of the other hosted providers wait in the gateway -inbox too. Any other hosted chat provider refuses: while it is switched off, -Nerve rejects its events and continues to read the events of the other -providers. Nerve also stops reading, for every provider, while one hosted -channel has 100 turns in progress, and continues when one of them ends. +inbox too. Any other hosted chat provider refuses, email included: while it +is switched off, Nerve rejects its events and continues to read the events +of the other providers. Nerve also stops reading, for every provider, while +one hosted channel has 100 turns in progress, and continues when one of them +ends. An event on a connection whose capabilities name another provider is rejected, and Nerve sends no operation of a channel through such a @@ -1774,6 +1782,74 @@ Three differences from the inbound policy: `G` target costs one cached lookup; a lookup that cannot answer is refused rather than guessed. +## Email + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `email.enabled` | bool | `false` | Take the mail that members of the agent send to its address. Hosted mode only | + +```yaml +email: + enabled: true +``` + +In hosted mode, the platform can give the agent an email address. The channel +gateway delivers the mail that members of the agent send to that address, on +the same streams as hosted Slack and the hosted GitHub source (see +[Hosted mode](#hosted-mode)). The platform decides which mail reaches the +agent; `email.enabled` decides only whether Nerve takes it. Email has no +other mode, so it needs usable `channels.hosted` settings. Without them, +Nerve logs a warning at startup, `nerve doctor` shows the same warning, and +there is no email channel. + +Each mail starts a turn. The conversation of a mail is one sender and one +email thread: the gateway gives each pair its own conversation ID, so two +members never share a session, and the session key is +`email:`. A conversation continues its last session however +long it was idle, until that session is archived or deleted. + +Nerve posts no answer to a mail: no typing indicator, no streamed answer, and +no `Error:` message. The answer stays in the session. The agent answers by +email only with the reply tool that the gateway names for the email +connection, and only when sending email is on for the agent. The text of the +turn starts with the line of [Connection behaviour](#connection-behaviour), +then the mail as the gateway gives it: the sender's name and member ID, the +subject, the date, the message and thread IDs, the attachments by name (they +are not delivered), and the body. The agent never sees the sender's email +address. + +The gateway takes mail for the agent only while the agent's subscriptions +name `email`, and Nerve names it while `email.enabled` is `true`. Usable +`channels.hosted` settings at startup give the email channel also while +`email.enabled` is `false`, and Nerve reads the switch for each event. Thus +a reload switches email on or off without a restart: + +- Switched on, the reload sends the subscriptions with `email` on every + stream, and the gateway takes new mail. +- Switched off, the reload sends them without `email`, and the gateway + refuses new mail. Nerve rejects the mail that is still in the gateway + inbox, and continues to read the events of the other providers. + +The gateway changes only when a stream sends it the subscriptions. When no +stream is open, or no open stream sends them in time, the reload reports +`subscriptions` with a note that says they are not sent yet. The note is +not an error, and the reload stays `ok`: each stream sends the current +subscriptions when it connects, before Nerve reads from it, so the gateway +gets them. Until then the gateway keeps the subscriptions that it had: +after a switch off, it can store more mail, and Nerve rejects that mail +when it reads it. That is the result of the switch, not a fault. + +A reload while `email.enabled` is `true` also says what stops the channel: +without usable `channels.hosted` settings, the reload repeats the startup +warning under `email`. When the settings are usable but this process has no +email channel, because the settings became usable after startup or the +hosted channels did not start, it says that a restart is needed. + +While a hosted Slack is switched off, Nerve reads nothing from the gateway +inbox, so member mail waits there too (see +[Connection behaviour](#connection-behaviour)). `send_channel_message` +cannot post through the email channel. + ## Notification delivery `notify`, `ask_user` and `propose_action` go to each channel in diff --git a/nerve/channels/hosted/channel.py b/nerve/channels/hosted/channel.py index e47815f40..424279073 100644 --- a/nerve/channels/hosted/channel.py +++ b/nerve/channels/hosted/channel.py @@ -831,10 +831,14 @@ async def authorize_outbound(self, target: str) -> Decision: return Decision(False, f"{self._provider} is switched off ({self._provider}.enabled is false)") settings = self._settings() if not settings.allow_outbound: + setting = self._spec.outbound_setting + if not setting: + return Decision( + False, f"the {self._provider} channel does not post to a conversation that the agent names", + ) return Decision( False, - f"{self._provider}.allow_outbound is not enabled, so the agent may not post " - "to a conversation it names", + f"{setting} is not enabled, so the agent may not post to a conversation it names", ) conversation, _ = parse_target(target) if not conversation: diff --git a/nerve/channels/hosted/contract/model.py b/nerve/channels/hosted/contract/model.py index 15731ddf5..73d856847 100644 --- a/nerve/channels/hosted/contract/model.py +++ b/nerve/channels/hosted/contract/model.py @@ -69,6 +69,9 @@ SUBSCRIPTION_INBOXES = ("", "participating", "all") # The provider whose subscription names repositories to watch, and only it. REPO_WATCH_PROVIDER = "github_repos" +# The provider of the mail that members send to the agent. Its subscription +# names only the provider, with empty filters. +EMAIL_PROVIDER = "email" # Such a subscription names 1 to this many repositories, each once. MAX_REPO_WATCHES = 50 # What a watched repository may report. @@ -869,6 +872,8 @@ class SourceSubscription: watches. Only that provider carries them, and it carries no filters, ``pr_watch`` or ``inbox``. Nerve sends it only while the repository watch is switched on, so every other frame is unchanged. + + The ``email`` subscription carries only its provider and empty filters. """ provider: str = "" @@ -894,8 +899,9 @@ def validate(self) -> None: The version, the number of sources, each provider name and that no provider appears twice, each inbox, the bounds of every filter list, - and the watched repositories, which only ``github_repos`` names (see - :func:`_validate_repo_watches`). + the watched repositories, which only ``github_repos`` names (see + :func:`_validate_repo_watches`), and that an ``email`` subscription + carries nothing but its provider. """ if self.version != SUBSCRIPTIONS_VERSION: reject(RejectionReason.VERSION_UNSUPPORTED, f"unknown subscriptions version {self.version!r}") @@ -920,6 +926,10 @@ def validate(self) -> None: _validate_repo_watches(source) elif source.repos: malformed(f"only a {REPO_WATCH_PROVIDER} subscription names repositories") + if source.provider == EMAIL_PROVIDER and ( + any(source.filters.pattern_lists().values()) or source.pr_watch or source.inbox + ): + malformed(f"an {EMAIL_PROVIDER} subscription carries filters, pr_watch or an inbox") def without_filters(self) -> Subscriptions: """The same sources with no filter patterns. diff --git a/nerve/channels/hosted/providers.py b/nerve/channels/hosted/providers.py index 137e9ba02..7bfde1de2 100644 --- a/nerve/channels/hosted/providers.py +++ b/nerve/channels/hosted/providers.py @@ -19,7 +19,8 @@ stops reading until a reload switches the provider on. The events wait in the gateway inbox, also those of the other providers. Slack does this. - Without it, which is the default, its channel rejects each of its events, - and the reader continues with the events of the other providers. + and the reader continues with the events of the other providers. Email + does this. """ from __future__ import annotations @@ -27,6 +28,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable +from nerve.channels.hosted.contract.model import EMAIL_PROVIDER + if TYPE_CHECKING: from nerve.config import ChannelSourceConfig, NerveConfig @@ -75,7 +78,15 @@ class HostedChatProvider: ``channel_options`` gives the provider's presentation rules, as keyword arguments of :class:`~nerve.channels.hosted.channel.HostedChannel`: ``is_id``, ``reaction_names`` and ``button_style``. A provider without - them gets the channel's defaults. + them gets the channel's defaults. ``subscribes`` says that the gateway + takes the provider's events only for an agent whose ``subscriptions`` + frame names the provider. While such a provider is switched on, the + frame carries an entry with its name and no filters; while it is off, + the frame leaves it out, and the gateway refuses its events before it + stores them. ``outbound_setting`` names the config key that lets the + agent post to a conversation that it names (``allow_outbound`` of the + settings), for the text of a refusal. It is empty for a provider without + such a key, which never allows it. """ name: str @@ -84,6 +95,8 @@ class HostedChatProvider: settings: Callable[[NerveConfig], HostedChannelSettings] = _no_settings hold_while_off: bool = False channel_options: Callable[[], dict[str, Any]] = _no_options + subscribes: bool = False + outbound_setting: str = "" # ---------------------------------------------------------------------- # @@ -134,10 +147,62 @@ def _slack_channel_options() -> dict[str, Any]: settings=_slack_settings, hold_while_off=True, channel_options=_slack_channel_options, + outbound_setting="slack.allow_outbound", +) + + +# ---------------------------------------------------------------------- # +# Email # +# ---------------------------------------------------------------------- # + + +def email_hosted(config: NerveConfig) -> bool: + """Whether the config gives Nerve an email channel. + + Email has no mode of its own: the channel gateway is the only way that + mail arrives, so usable ``channels.hosted`` settings give the channel, + also while ``email.enabled`` is false. A reload can then switch email on + without a restart. + """ + return not config.channels.hosted.problems() + + +def _email_switched_on(config: NerveConfig) -> bool: + return bool(config.email.enabled) + + +def email_warning(config: NerveConfig) -> str | None: + """Why ``email.enabled`` gives no email channel, or ``None``. + + ``None`` also while email is switched off. Nerve logs the text at + startup, and ``nerve doctor`` shows it. + """ + if not config.email.enabled: + return None + problems = config.channels.hosted.problems() + if not problems: + return None + return ( + "email.enabled is set, but mail comes only through the channel gateway and the " + f"channels.hosted settings are not usable ({'; '.join(problems)}); no email channel " + "is registered" + ) + + +# The mail that members of the agent send to its address. Each mail is a +# direct conversation of its own sender and thread. The gateway announces the +# connection's behaviour: Nerve posts no answer, and the conversation keeps +# its session. Email has no settings of its own, refuses its events while it +# is switched off, and the gateway takes mail only for a subscribed agent. +EMAIL = HostedChatProvider( + name=EMAIL_PROVIDER, + hosted=email_hosted, + switched_on=_email_switched_on, + subscribes=True, ) # Every hosted chat provider, in the order of their channels. -HOSTED_CHAT_PROVIDERS: tuple[HostedChatProvider, ...] = (SLACK,) +HOSTED_CHAT_PROVIDERS: tuple[HostedChatProvider, ...] = (SLACK, EMAIL) def hosted_chat_providers(config: NerveConfig) -> list[HostedChatProvider]: @@ -150,10 +215,13 @@ def hosted_chat_providers(config: NerveConfig) -> list[HostedChatProvider]: __all__ = [ + "EMAIL", "HOSTED_CHAT_PROVIDERS", "SLACK", "STREAM_MODE_PARTIAL", "HostedChannelSettings", "HostedChatProvider", + "email_hosted", + "email_warning", "hosted_chat_providers", ] diff --git a/nerve/channels/hosted/runtime.py b/nerve/channels/hosted/runtime.py index dd819c744..91c4fbe4e 100644 --- a/nerve/channels/hosted/runtime.py +++ b/nerve/channels/hosted/runtime.py @@ -5,13 +5,16 @@ hosted mode (see :mod:`nerve.channels.hosted.providers`), and one :class:`HostedSourceConsumer` for each hosted source. The hosted settings and the provider modes are read once at startup; a change needs a restart. A -reload reads the gateway key file again and publishes the source -subscriptions again. The switch, the settings, and the source grant of each -chat provider are read per event, so they follow a reload. +reload reads the gateway key file again and publishes the subscriptions +again: the hosted sources, and the chat providers that the frame names, such +as email, each while it is switched on. The switch, the settings, and the +source grant of each chat provider are read per event, so they follow a +reload. """ from __future__ import annotations +import dataclasses import logging import uuid from typing import TYPE_CHECKING, Any, Callable @@ -30,6 +33,7 @@ from nerve.channels.hosted.providers import hosted_chat_providers from nerve.channels.hosted.sources import ( HostedSourceConsumer, + chat_subscription, hosted_sources, subscriptions_for, ) @@ -119,6 +123,12 @@ def __init__( tenant_id=tenant_id, agent_id=agent_id, ) + # The chat providers that the subscriptions frame names while they + # are switched on (see HostedChatProvider.subscribes). A runtime with + # one of them sends the frame also while all of them are off, so that + # a reload that switches one off takes its entry away at the gateway. + self._subscribing = [provider for provider in providers if provider.subscribes] + self._publishes = bool(source_providers or self._subscribing) self.streams = StreamManager( verifier=self.verifier, receive_limits=RECEIVE_LIMITS, @@ -126,7 +136,7 @@ def __init__( timing=stream_timing, on_ready=self._stream_ready, on_nudge=self._stream_nudged, - subscriptions=self.subscriptions if source_providers else None, + subscriptions=self.subscriptions if self._publishes else None, ) self.operations = OperationRunner(self.streams, operation_timing) self.channels: dict[str, HostedChannel] = { @@ -188,22 +198,48 @@ def reload_keys(self) -> str: def subscriptions(self) -> Subscriptions | None: """The ``subscriptions`` payload from the current config. - ``None`` without hosted sources: such a runtime sends no frame, so a - gateway that predates the frame never receives one. + It names the hosted sources that are switched on, then each chat + provider that the frame names while it is switched on. ``None`` + without hosted sources and without such a chat provider: that + runtime sends no frame, so a gateway that predates the frame never + receives one. """ - if not self.sources: + if not self._publishes: return None - return subscriptions_for(self._config(), self.sources) + config = self._config() + body = subscriptions_for(config, self.sources) + chat = tuple( + chat_subscription(provider.name) + for provider in self._subscribing if provider.switched_on(config) + ) + return dataclasses.replace(body, sources=body.sources + chat) if chat else body async def republish_subscriptions(self) -> str | None: """Send the subscriptions again on every open stream, after a reload. - ``None`` without hosted sources, otherwise a summary for the reload. + ``None`` for a runtime that sends no frame (see + :meth:`subscriptions`), otherwise a summary for the reload. When no + stream sent them, the summary is a note that says so, not an error: + the gateway keeps the subscriptions that it had until a stream sends + the current ones, and each stream sends them when it connects, + before Nerve reads from it. """ - if not self.sources: + if not self._publishes: return None + ready = sum(1 for stream in self.streams.streams if stream.ready) sent = await self.streams.publish_subscriptions() - return f"published on {sent} stream(s)" + if sent: + return f"published on {sent} stream(s)" + if not ready: + return ( + "not sent yet: no gateway stream is open; the gateway gets them when a " + "stream connects, before Nerve reads from it" + ) + return ( + f"not sent yet: none of the {ready} open stream(s) sent them in time; the " + "gateway gets them when a stream connects, before Nerve reads from it, or " + "at the next reload" + ) def set_delivery_listener(self, listener: Callable[[str], Any] | None) -> None: """Call *listener* with the provider name whenever a source record is stored.""" diff --git a/nerve/channels/hosted/sources.py b/nerve/channels/hosted/sources.py index 3acd0f247..20724ecc2 100644 --- a/nerve/channels/hosted/sources.py +++ b/nerve/channels/hosted/sources.py @@ -17,7 +17,9 @@ :func:`subscriptions_for` builds the ``subscriptions`` frame from the same settings that build the inbox filter, so the gateway and Nerve drop the same -records. +records. The runtime adds :func:`chat_subscription` entries for the hosted +chat providers whose events the gateway takes only for a subscribed agent, +such as ``email``, while they are switched on. """ from __future__ import annotations @@ -100,6 +102,18 @@ def subscriptions_for(config: NerveConfig, providers: Iterable[str]) -> Subscrip return Subscriptions(version=SUBSCRIPTIONS_VERSION, sources=tuple(sources)) +def chat_subscription(provider: str) -> SourceSubscription: + """The subscription of a hosted chat provider: its name with empty filters. + + The gateway takes the events of such a provider, such as ``email``, only + for an agent whose frame names it. The platform decides which of them + reach the agent, so there is nothing to filter. A member that the + gateway does not take would make it close the stream, so the entry + carries none. + """ + return SourceSubscription(provider=provider) + + def github_subscription(settings: GitHubSyncConfig) -> SourceSubscription: """The GitHub subscription, with the inbox guardrail's patterns as filters. @@ -337,6 +351,7 @@ async def stop(self) -> None: "GITHUB_REPOS", "HostedSourceConsumer", "buffered_record", + "chat_subscription", "github_repos_subscription", "github_subscription", "hosted_sources", diff --git a/nerve/channels/hosted/stream.py b/nerve/channels/hosted/stream.py index 4f99375c4..5fd310ecc 100644 --- a/nerve/channels/hosted/stream.py +++ b/nerve/channels/hosted/stream.py @@ -23,9 +23,10 @@ size, together with the uploads already in flight, fits the gateway's ``memory_bytes``. -When the agent has hosted sources, the stream sends its ``subscriptions`` -frame as soon as negotiation completes, and again whenever the runtime -publishes changed settings. The first frame waits for the stream for as long +When the runtime publishes subscriptions (for hosted sources, or for a chat +provider such as email), the stream sends its ``subscriptions`` frame as soon +as negotiation completes, and again whenever the runtime publishes changed +settings. The first frame waits for the stream for as long as it is open; a republish gives up after a short bound, so a stalled stream does not hold up a reload. A subscription that the gateway's bounds or frame limit would refuse goes without its filter patterns, so it never closes the @@ -183,8 +184,8 @@ class ChannelStream: ``receive_limits`` is what Nerve accepts on this stream. Nerve advertises the same limits on every stream, so a row that one stream cannot carry cannot be carried by another either. ``subscriptions`` returns the - agent's current source subscriptions, or ``None`` when it has no hosted - sources and sends no ``subscriptions`` frame. + agent's current subscriptions, or ``None`` when the runtime sends no + ``subscriptions`` frame. """ def __init__( diff --git a/nerve/cli.py b/nerve/cli.py index e0b81575a..e6a30c2a3 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -1678,6 +1678,37 @@ def _hosted_github_lines(config, errors: list[str], warnings: list[str]) -> list return lines +def _email_lines(config, errors: list[str], warnings: list[str]) -> list[str]: + """Doctor lines for the email channel (``email.enabled``). + + Mail comes only through the channel gateway, so the channel needs usable + ``channels.hosted`` settings and a key file that can be read. A + switched-off hosted Slack holds member mail back too. Nothing is shown + while email is switched off. + """ + if not config.email.enabled: + return [] + from nerve.channels.hosted.auth import KeyFileError, read_key_file + from nerve.channels.hosted.providers import email_warning + + problem = email_warning(config) + if problem is not None: + warnings.append(f"[WARN] {problem}") + return [] + try: + read_key_file(config.channels.hosted.gateway_jwks_file) + except KeyFileError as error: + errors.append(f"[ERR] Email is on but channels.hosted.gateway_jwks_file: {error}") + return [] + if config.slack.mode == "hosted" and not config.slack.enabled: + warnings.append( + "[WARN] Slack is hosted but switched off (slack.enabled is false): Nerve " + "reads nothing from the channel gateway until it is switched on, so member " + "mail waits there too" + ) + return ["[OK] Email from members comes from the channel gateway; Nerve posts no answer"] + + def _actor_context_lines(config, errors: list[str], warnings: list[str]) -> list[str]: """Doctor lines for the actor context check of external mode (``auth.external``).""" from nerve.config import EXTERNAL_VERIFICATION_LOG @@ -2231,6 +2262,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s lines.append("[--] Slack disabled") lines.extend(_hosted_github_lines(config, errors, warnings)) lines.extend(_github_repos_lines(config, warnings)) + lines.extend(_email_lines(config, errors, warnings)) # Check SSL if config.gateway.ssl.enabled: diff --git a/nerve/config.py b/nerve/config.py index 4aab519a1..057549ce9 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1765,6 +1765,28 @@ def from_dict(cls, d: dict, locked: bool = False) -> SlackConfig: ) +@dataclass +class EmailConfig: + """The email channel: mail that members of the agent send to its address. + + Hosted mode only. The channel gateway delivers each member mail as an + event that starts or continues a session, and Nerve posts no answer: the + agent answers with a tool. The platform decides which mail reaches the + agent; ``enabled`` decides only whether Nerve takes it. The channel + exists when the ``channels.hosted`` settings are usable at startup, and + ``enabled`` is read for each event, so a reload switches it on or off. + Without usable ``channels.hosted`` settings, Nerve logs a warning and + registers no email channel. See docs/config.md "Email". + """ + + enabled: bool = False + + @classmethod + @_coerced + def from_dict(cls, d: dict) -> EmailConfig: + return cls(enabled=d.get("enabled", False)) + + @dataclass class TelegramSyncConfig: enabled: bool = True @@ -4058,6 +4080,7 @@ class NerveConfig: agent: AgentConfig = field(default_factory=AgentConfig) telegram: TelegramConfig = field(default_factory=TelegramConfig) slack: SlackConfig = field(default_factory=SlackConfig) + email: EmailConfig = field(default_factory=EmailConfig) sync: SyncConfig = field(default_factory=SyncConfig) memory: MemoryConfig = field(default_factory=MemoryConfig) cron: CronConfig = field(default_factory=CronConfig) @@ -4471,6 +4494,7 @@ def _build_from_dict(cls, d: dict) -> NerveConfig: agent=AgentConfig.from_dict(d.get("agent", {})), telegram=TelegramConfig.from_dict(d.get("telegram", {}), locked=locked), slack=SlackConfig.from_dict(d.get("slack", {}), locked=locked), + email=EmailConfig.from_dict(d.get("email") or {}), sync=SyncConfig.from_dict(d.get("sync", {})), memory=MemoryConfig.from_dict(d.get("memory", {})), cron=CronConfig.from_dict(d.get("cron", {}), workspace=workspace, locked=locked), diff --git a/nerve/config_reload.py b/nerve/config_reload.py index ce50c1380..d26737dd1 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -373,13 +373,17 @@ def _reload_actor_context_keys() -> str | None: async def _republish_hosted_subscriptions(engine) -> str | None: - """Send the hosted source subscriptions to the gateway again. + """Send the hosted subscriptions to the gateway again. The filters, ``pr_watch`` and ``inbox`` follow a reload this way, and so - does the repository watch (``sync.github_repos``: switched on or off, its - repositories and settings); the gateway keeps the newest subscription it - received. (In poll mode they follow with the rebuilt source runners.) - ``None`` without hosted sources. + do the repository watch (``sync.github_repos``: switched on or off, its + repositories and settings) and ``email.enabled``; the gateway keeps the + newest subscription it received. (In poll mode they follow with the + rebuilt source runners.) ``None`` for a runtime that sends no + subscriptions. When no stream sent them, a "not sent yet" note, not an + error: every stream that connects sends the current subscriptions before + Nerve reads from it, so the gateway gets them, and an error would make + workspace sync run the whole reload again on every cycle. """ if engine is None: return None @@ -390,10 +394,42 @@ async def _republish_hosted_subscriptions(engine) -> str | None: return await runtime.republish_subscriptions() except Exception as e: # noqa: BLE001 - report the subsystem and continue detail = str(e) or type(e).__name__ - logger.warning("Hosted source subscriptions were not published: %s", detail) + logger.warning("Hosted subscriptions: %s", detail) return f"{_ERROR_PREFIX}{detail}" +def _email_reload_notes(new_config, engine) -> tuple[str | None, str | None]: + """What a reload says about ``email.enabled``: a warning and a restart line. + + Nothing while email is switched off or while its channel runs. Without + usable ``channels.hosted`` settings, the warning of the startup check. + With usable settings but no email channel in this process, because the + settings became usable after startup or the hosted runtime did not + start, a restart line: the channel is built only at startup. + """ + email = getattr(new_config, "email", None) + if not getattr(email, "enabled", False): + return None, None + from nerve.channels.hosted.providers import EMAIL, email_warning + + warning = email_warning(new_config) + if warning is not None: + logger.warning("config reload: %s", warning) + return warning, None + try: + runtime = engine.get_channel_runtime("hosted") if engine is not None else None + except Exception: # noqa: BLE001 - a stand-in engine without runtimes + runtime = None + if EMAIL.name in (getattr(runtime, "channels", None) or {}): + return None, None + note = ( + "email.enabled is set and the channels.hosted settings are usable, but this " + "process has no email channel: it is built at startup, so restart Nerve" + ) + logger.warning("config reload: %s", note) + return note, "email.enabled: the email channel starts at the next restart" + + async def reload_all(engine, cron_service, config_dir: Path) -> dict: """Re-read config and hot-reload all reloadable subsystems. @@ -425,6 +461,11 @@ async def reload_all(engine, cron_service, config_dir: Path) -> dict: # Not an error — the reload applied everything it can. Reported so the # caller can tell "applied" from "cannot apply without a restart". pending = restart_required(old_config, new_config) + email, email_restart = _email_reload_notes(new_config, engine) + if email is not None: + summary["email"] = email + if email_restart is not None: + pending.append(email_restart) if pending: summary["restart_required"] = "; ".join(pending) logger.warning( diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index cf45b5c3c..7781b0fe1 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -644,9 +644,15 @@ async def _unwind_startup() -> None: # Hosted channels and sources: a shared gateway holds the provider # connections, polls the hosted sources, and streams admitted events to # /_internal/channel/v1/stream. Optional like Slack: a failure leaves - # the endpoint refusing upgrades. + # the endpoint refusing upgrades. Usable channels.hosted settings give + # the email channel, so a reload can switch email on; without them, + # email.enabled gets a warning and no channel. + from nerve.channels.hosted.providers import email_warning from nerve.channels.hosted.runtime import HostedChannelRuntime, hosted_providers + email_problem = email_warning(config) + if email_problem is not None: + logger.warning("%s", email_problem) if hosted_providers(config): try: runtime = HostedChannelRuntime( diff --git a/tests/fixtures/channel_contract/email_message.json b/tests/fixtures/channel_contract/email_message.json new file mode 100644 index 000000000..553a14553 --- /dev/null +++ b/tests/fixtures/channel_contract/email_message.json @@ -0,0 +1,47 @@ +{ + "event_id": "email:0100019a2b3c4d5e-example-message-0001-000000", + "message_revision_id": "0100019a2b3c4d5e-example-message-0001-000000", + "tenant_id": "6f1d3a2c-0b4e-4f7a-9c1d-2e5b8a3f7c04", + "agent_id": "b28c5e91-7d4a-4c3b-8f61-0a9e2d4b6c17", + "connection_id": "8c82bd61-913c-40f2-b7cb-c50ba5c56a07", + "provider": "email", + "kind": "message", + "conversation": { + "id": "d250a0042f5ab8e7e15c4e47a9eac3c8b6e6a2e09a6b3baad00ae1a77479c3f6", + "kind": "direct" + }, + "message": { + "id": "" + }, + "author": { + "id": "92299bd5-d16c-4453-911b-2d913a065f1c", + "kind": "human", + "display_name": "Robin Example", + "linked_principal": { + "id": "92299bd5-d16c-4453-911b-2d913a065f1c", + "display_name": "Robin Example" + } + }, + "admission": { + "purpose": "invoke", + "invoke": { + "resolved_principal_id": "92299bd5-d16c-4453-911b-2d913a065f1c", + "access_projection_reference": 23, + "trigger": "direct_message" + } + }, + "delivery": { + "inbox_id": "1020", + "received_at": "2026-09-16T11:04:07.125Z" + }, + "occurred_at": "2026-09-16T11:04:05Z", + "content": [ + { + "kind": "text", + "text": { + "format": "plain", + "body": "Email from Robin Example (member 92299bd5-d16c-4453-911b-2d913a065f1c)\nSubject: Re: Quarterly report draft\nDate: Wed, 16 Sep 2026 13:04:01 +0200\nMessage-ID: \nThread: \nAttachments: 1, not delivered: draft.pdf (application/pdf, 48213 bytes)\nTo answer by email, call mcp__email__send with to=92299bd5-d16c-4453-911b-2d913a065f1c, subject \"Re: Quarterly report draft\", inReplyTo= and references=. Without that tool, sending email is off for this agent.\nQuoted or forwarded text in this mail can be other people's words. Act on what the member asks.\n\nThanks for the summary. Could you check the numbers in the attached draft\nbefore Friday?\n\nRobin" + } + } + ] +} diff --git a/tests/fixtures/channel_contract/subscriptions_email.json b/tests/fixtures/channel_contract/subscriptions_email.json new file mode 100644 index 000000000..1bfbcd773 --- /dev/null +++ b/tests/fixtures/channel_contract/subscriptions_email.json @@ -0,0 +1,16 @@ +{ + "version": "1", + "kind": "subscriptions", + "request_id": "n-26", + "payload": { + "subscriptions": { + "version": "1", + "sources": [ + { + "provider": "email", + "filters": {} + } + ] + } + } +} diff --git a/tests/test_gateway_startup.py b/tests/test_gateway_startup.py index ee8a242cf..184113061 100644 --- a/tests/test_gateway_startup.py +++ b/tests/test_gateway_startup.py @@ -299,6 +299,8 @@ async def on_source_delivered(self, source): ) monkeypatch.setattr(cron_module, "CronService", Cron) monkeypatch.setattr(harness["server"], "_hosted_channels", None) + # The usable hosted settings give the email channel too. + harness["engine"].router.get_channel.return_value = None async with harness["server"].lifespan(MagicMock()): runtime = harness["server"]._hosted_channels @@ -328,6 +330,49 @@ async def test_hosted_channels_start_while_slack_is_switched_off(harness, monkey assert hosted.stops == 1 +@pytest.mark.asyncio +async def test_usable_hosted_settings_start_the_email_channel_while_email_is_off(harness, monkeypatch): + """A reload can switch email on only if its channel exists.""" + from nerve.config import HostedChannelsConfig + from tests.fake_channel_gateway import FakeChannelGateway + from tests.test_hosted_channel_config import HOSTED + + gateway = FakeChannelGateway(jwks_file=harness["config"].workspace.parent / "gateway-jwks.json") + harness["config"].channels.hosted = HostedChannelsConfig.from_dict( + {**HOSTED, "gateway_jwks_file": str(gateway.jwks_file)}, + ) + harness["engine"].router.get_channel.return_value = None + monkeypatch.setattr(harness["server"], "_hosted_channels", None) + + async with harness["server"].lifespan(MagicMock()): + runtime = harness["server"]._hosted_channels + assert runtime is not None + assert (set(runtime.channels), runtime.sources) == ({"email"}, {}) + harness["engine"].router.register.assert_called_once_with(runtime.channels["email"]) + + +@pytest.mark.asyncio +async def test_email_without_hosted_settings_is_a_warning_and_starts_nothing(harness, monkeypatch, caplog): + import nerve.channels.hosted.runtime as hosted_module + + hosted = _Recorder() + monkeypatch.setattr(hosted_module, "HostedChannelRuntime", lambda *a, **k: hosted) + monkeypatch.setattr(harness["server"], "_hosted_channels", None) + harness["config"].email.enabled = True + + with caplog.at_level("WARNING", logger="nerve.gateway.server"): + async with harness["server"].lifespan(MagicMock()): + assert harness["server"]._hosted_channels is None + + assert hosted.starts == 0 + [warning] = [ + record.getMessage() for record in caplog.records + if record.name == "nerve.gateway.server" and "email.enabled" in record.getMessage() + ] + assert warning.startswith("email.enabled is set, but mail comes only through the channel gateway") + assert "channels.hosted.gateway_jwks_file must name the gateway's public key file" in warning + + class _Reporter(_Recorder): """An activity reporter that records how the lifespan drives it.""" diff --git a/tests/test_hosted_channel_behaviour.py b/tests/test_hosted_channel_behaviour.py index 11fff8635..a49411d77 100644 --- a/tests/test_hosted_channel_behaviour.py +++ b/tests/test_hosted_channel_behaviour.py @@ -46,6 +46,7 @@ from nerve.channels.hosted.operations import OperationFailed from nerve.channels.hosted import providers as providers_module from nerve.channels.hosted.providers import ( + EMAIL, HOSTED_CHAT_PROVIDERS, SLACK, HostedChannelSettings, @@ -145,9 +146,9 @@ async def announce(stream, provider: str | None, connection_id: str, **behaviour class TestProviderTable: - def test_slack_is_the_only_provider_and_holds_its_events_while_off(self): - assert HOSTED_CHAT_PROVIDERS == (SLACK,) - assert SLACK.hold_while_off + def test_slack_holds_its_events_while_off_and_email_refuses_them(self): + assert HOSTED_CHAT_PROVIDERS == (SLACK, EMAIL) + assert SLACK.hold_while_off and not EMAIL.hold_while_off def test_a_provider_is_hosted_as_its_config_says(self): config = NerveConfig() @@ -209,7 +210,8 @@ async def test_it_refuses_addressed_delivery_and_observations(self, two): assert two.gateway.acks[-1][0]["reason_code"] == "policy_denied" verdict = await channel.authorize_outbound("room-1") assert not verdict.allowed - assert "allow_outbound" in verdict.reason + # The provider has no outbound setting, so the refusal names none. + assert verdict.reason == "the fixture_chat channel does not post to a conversation that the agent names" async def test_switched_off_it_refuses_its_events_and_slack_goes_on(self, two, switch): switch.on = False diff --git a/tests/test_hosted_channel_config.py b/tests/test_hosted_channel_config.py index 020eb56a9..26e1346f1 100644 --- a/tests/test_hosted_channel_config.py +++ b/tests/test_hosted_channel_config.py @@ -58,7 +58,8 @@ def test_hosted_mode_turns_slack_on_without_tokens(self): assert (config.slack.enabled, config.slack.mode) == (True, "hosted") assert config.slack.bot_token == config.slack.app_token == "" - assert hosted_providers(config) == ["slack"] + # The usable hosted settings give the email channel too. + assert hosted_providers(config) == ["slack", "email"] def test_hosted_mode_under_lockdown_needs_no_local_opt_in(self): config = NerveConfig.from_dict({"lockdown": True, "slack": {"mode": "hosted"}}) diff --git a/tests/test_hosted_email_channel.py b/tests/test_hosted_email_channel.py new file mode 100644 index 000000000..a5720b3d5 --- /dev/null +++ b/tests/test_hosted_email_channel.py @@ -0,0 +1,553 @@ +"""The email channel: the ``email`` setting, its provider entry, its +subscription, member mail over real streams, and ``nerve doctor``. + +``fixtures/channel_contract`` holds copies of the channel contract's samples: +the ``email_message`` event as the gateway delivers member mail, the +``capabilities_behaviour`` frame that announces the email connection, and the +``subscriptions_email`` frame. Nerve must send that frame exactly, and must +take that event into a turn of the session of its conversation without +posting anything. + +The end-to-end tests run the fake gateway against the real stream endpoint, +with a real router, session manager, and database; only the agent turn is a +stand-in, and it streams an answer into its session. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import uuid +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +from nerve import config_reload +from nerve.channels.hosted.contract import ( + Envelope, + Event, + Payload, + Rejected, + SourceFilters, + SourceSubscription, + Subscriptions, + encode_envelope, +) +from nerve.channels.hosted.contract.wire import decode_record, dumps +from nerve.channels.hosted.providers import ( + EMAIL, + HOSTED_CHAT_PROVIDERS, + HostedChannelSettings, + email_warning, +) +from nerve.channels.hosted.runtime import hosted_channel_providers, hosted_providers +from nerve.channels.hosted.sources import chat_subscription +from nerve.cli import doctor_report +from nerve.config import ( + EmailConfig, + GitHubSyncConfig, + NerveConfig, + SlackConfig, + validate_config_keys, +) +from tests.fake_channel_gateway import CONNECTION_ID, FakeChannelGateway, message_event +from tests.test_hosted_channel import Hosted, start_hosted, stop_hosted +from tests.test_hosted_channel_behaviour import StreamingTurns, kinds, turn_of +from tests.test_hosted_channel_config import HOSTED +from tests.test_hosted_sources import start, stop + +FIXTURES = Path(__file__).parent / "fixtures" / "channel_contract" + + +def fixture(name: str) -> dict[str, Any]: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +EVENT = fixture("email_message.json") +FRAME = fixture("subscriptions_email.json") +ANNOUNCEMENT = fixture("capabilities_behaviour.json") +EMAIL_CONNECTION = uuid.UUID(ANNOUNCEMENT["connection_id"]) +CONVERSATION = EVENT["conversation"]["id"] +CHANNEL_KEY = f"email:{CONVERSATION}" +MAIL_TEXT = EVENT["content"][0]["text"]["body"] +# The line that Nerve puts before the mail, from the announced behaviour. +NOTE = ( + "This message came through email. Your answer in this session is not sent to anyone. " + "To answer, use the mcp__email__send tool." +) +# Long before any sticky period. +LONG_AGO = "2020-01-01T00:00:00+00:00" + + +def mail(number: int = 1, *, conversation: str = CONVERSATION) -> dict[str, Any]: + """The contract's email event without its delivery, as the fake gateway + stores it. Another *number* is another mail of the same thread, with its + own Message-ID and event ID; another *conversation* is another thread.""" + event = copy.deepcopy(EVENT) + del event["delivery"] + if number != 1: + receipt = f"0100019a2b3c4d5e-example-message-{number:04d}-000000" + event["event_id"] = f"email:{receipt}" + event["message_revision_id"] = receipt + event["message"] = {"id": f""} + event["conversation"]["id"] = conversation + return event + + +def hosted_config(**email: Any) -> NerveConfig: + """A config with usable hosted settings and nothing else hosted.""" + config = NerveConfig.from_dict({"channels": {"hosted": HOSTED}}) + config.email = EmailConfig(**email) + return config + + +async def announce(hosted: Hosted, stream) -> None: + """Announce the Slack connection, then the email connection with the contract's frame.""" + await stream.advertise( + connection_id=CONNECTION_ID, provider="slack", behaviour={"answer": "post", "session": "rotate"}, + ) + await stream.send( + "capabilities", ANNOUNCEMENT["payload"]["capabilities"], connection_id=ANNOUNCEMENT["connection_id"], + ) + await hosted.gateway.wait_for( + lambda: hosted.runtime.streams.capabilities_for(EMAIL_CONNECTION) is not None, + ) + + +async def idle_session(hosted: Hosted, channel_key: str = CHANNEL_KEY) -> str: + """The conversation's session, idle since long ago.""" + session_id = await hosted.router.create_session(channel_key, source="email") + await hosted.db.update_session_fields(session_id, {"status": "idle", "last_activity_at": LONG_AGO}) + return session_id + + +def republisher(runtime) -> SimpleNamespace: + """An engine that holds only the hosted runtime, for the reload step.""" + return SimpleNamespace(get_channel_runtime={"hosted": runtime}.get) + + +@pytest_asyncio.fixture +async def hosted(tmp_path, db, monkeypatch): + """Hosted Slack and the email channel, with email switched on.""" + harness = await start_hosted(tmp_path, db, monkeypatch) + harness.config.email.enabled = True + try: + yield harness + finally: + await stop_hosted(harness) + + +# ---------------------------------------------------------------------- # +# The contract samples # +# ---------------------------------------------------------------------- # + + +# The SHA-256 of the contract's samples, which the copies must match byte +# for byte: testdata/events/email_message.json and +# testdata/frames/nerve/subscriptions_email.json. +SAMPLE_SHA256 = { + "email_message.json": "3c03f271d08846824f3842f67b9b10bc68b5ca4c3ed49d24f825571d2fd5ddb1", + "subscriptions_email.json": "e3a1b26391acc2785d127a0b9e275be9c1395f316478dfd6c1998adccc36e1b9", +} + + +class TestContractSamples: + @pytest.mark.parametrize("name", sorted(SAMPLE_SHA256)) + def test_the_copy_of_the_contract_sample_has_its_exact_bytes(self, name): + assert hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() == SAMPLE_SHA256[name] + + def test_the_email_event_is_an_invocation_by_its_member(self): + event = decode_record(Event, EVENT) + event.validate() + principal = event.admission.invoke.resolved_principal_id + + assert (event.provider, event.kind, event.connection_id) == ("email", "message", EMAIL_CONNECTION) + assert (event.conversation.kind, len(event.conversation.id)) == ("direct", 64) + assert event.author.id == str(principal) == str(event.author.linked_person.id) + assert event.attachments == () + assert [part.text.format for part in event.content] == ["plain"] + assert not {"issuer", "installation_id", "attachments", "extensions"} & set(EVENT) + + +# ---------------------------------------------------------------------- # +# Settings and the provider entry # +# ---------------------------------------------------------------------- # + + +class TestSettings: + def test_email_is_off_by_default_and_the_setting_is_read(self): + assert NerveConfig().email == EmailConfig() == EmailConfig.from_dict({}) + assert EmailConfig().enabled is False + assert NerveConfig.from_dict({"email": {"enabled": "true"}}).email.enabled is True + assert NerveConfig.from_dict({"email": None}).email.enabled is False + assert validate_config_keys({"email": {"enabled": True}}) == [] + assert validate_config_keys({"email": {"enabeld": True}}) == [ + "unknown key 'email.enabeld' — it is ignored (typo or removed option?)", + ] + + def test_email_is_a_hosted_chat_provider_that_refuses_while_off_and_subscribes(self): + assert EMAIL in HOSTED_CHAT_PROVIDERS + assert (EMAIL.name, EMAIL.hold_while_off, EMAIL.subscribes) == ("email", False, True) + assert EMAIL.settings(hosted_config()) == HostedChannelSettings() + assert EMAIL.channel_options() == {} + + def test_usable_hosted_settings_give_the_email_channel_also_while_email_is_off(self): + config = hosted_config() + + assert hosted_channel_providers(config) == hosted_providers(config) == ["email"] + assert not EMAIL.switched_on(config) + config.email.enabled = True + assert EMAIL.switched_on(config) + config.slack = SlackConfig(mode="hosted") + config.sync.github = GitHubSyncConfig(mode="hosted") + assert hosted_providers(config) == ["slack", "email", "github", "github_repos"] + + @pytest.mark.parametrize("settings", [ + {}, + {**HOSTED, "gateway_jwks_file": "relative/gateway-jwks.json"}, + {**HOSTED, "agent_id": "not-a-uuid"}, + ], ids=["absent", "relative-key-file", "agent-id"]) + def test_without_usable_hosted_settings_there_is_no_email_channel(self, settings): + config = NerveConfig.from_dict({"email": {"enabled": True}, "channels": {"hosted": settings}}) + + assert hosted_providers(config) == [] + warning = email_warning(config) + assert warning.startswith( + "email.enabled is set, but mail comes only through the channel gateway and the " + "channels.hosted settings are not usable (", + ) + assert warning.endswith("); no email channel is registered") + assert "; ".join(config.channels.hosted.problems()) in warning + + def test_a_switched_off_email_gives_no_warning(self): + assert email_warning(NerveConfig()) is None + assert email_warning(hosted_config()) is None + assert email_warning(hosted_config(enabled=True)) is None + + def test_the_switch_needs_no_restart(self): + off, on = hosted_config(), hosted_config(enabled=True) + + assert config_reload.restart_required(off, on) == [] + assert config_reload.restart_required(on, off) == [] + + +# ---------------------------------------------------------------------- # +# The subscription entry # +# ---------------------------------------------------------------------- # + + +class TestSubscriptionEntry: + def test_the_entry_carries_the_provider_and_empty_filters_only(self): + assert dumps(chat_subscription("email")) == '{"provider":"email","filters":{}}' + + @pytest.mark.parametrize("source", [ + SourceSubscription(provider="email", filters=SourceFilters(deny_actors=("someone",))), + SourceSubscription(provider="email", pr_watch=True), + SourceSubscription(provider="email", inbox="all"), + ], ids=["filter", "pr-watch", "inbox"]) + def test_an_email_subscription_with_another_member_is_not_sent(self, source): + """The gateway refuses it and closes the stream, so Nerve checks first.""" + with pytest.raises(Rejected) as refused: + Subscriptions(version="1", sources=(source,)).validate() + + assert refused.value.reason == "malformed_frame" + + +# ---------------------------------------------------------------------- # +# Member mail over real streams # +# ---------------------------------------------------------------------- # + + +@pytest.mark.asyncio +class TestMemberMail: + async def test_the_contract_event_starts_a_turn_in_its_conversation_and_posts_nothing(self, hosted): + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + + run = await turn_of(hosted, stream, mail(), StreamingTurns()) + + mapping = await hosted.db.get_channel_session(CHANNEL_KEY) + assert (run["session_id"], run["channel"], run["source"]) == (mapping["session_id"], "email", "email") + assert run["user_message"] == f"{NOTE}\n\n{MAIL_TEXT}" + assert run["actor"] is None + assert kinds(hosted) == [] + assert hosted.gateway.acks[-1][0]["outcome"] == "accepted" + + async def test_the_conversation_keeps_its_session_past_the_sticky_period(self, hosted): + old = await idle_session(hosted) + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + turns = StreamingTurns() + + first = await turn_of(hosted, stream, mail(1), turns) + await hosted.db.update_session_fields(old, {"status": "idle", "last_activity_at": LONG_AGO}) + second = await turn_of(hosted, stream, mail(2), turns) + + assert first["session_id"] == second["session_id"] == old + assert (await hosted.db.get_channel_session(CHANNEL_KEY))["session_id"] == old + assert kinds(hosted) == [] + + async def test_another_thread_of_the_sender_has_its_own_session(self, hosted): + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + turns = StreamingTurns() + other = "0" * 64 + + first = await turn_of(hosted, stream, mail(1), turns) + second = await turn_of(hosted, stream, mail(2, conversation=other), turns) + + assert first["session_id"] != second["session_id"] + assert (await hosted.db.get_channel_session(f"email:{other}"))["session_id"] == second["session_id"] + + async def test_while_email_is_off_its_mail_is_refused_and_slack_goes_on(self, hosted): + hosted.config.email.enabled = False + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + refused = hosted.gateway.store(mail()) + taken = hosted.gateway.store(message_event(conversation="D0DIRECT", conversation_kind="direct")) + + await stream.nudge("invoke") + + assert await hosted.acknowledged(refused, taken) == {refused: "rejected", taken: "accepted"} + [rejection] = [item for ack in hosted.gateway.acks for item in ack if item["inbox_id"] == refused] + assert rejection["reason_code"] == "configuration_invalid" + await hosted.turns.wait_for(1) + assert [run["channel"] for run in hosted.turns.runs] == ["slack"] + assert await hosted.db.get_channel_session(CHANNEL_KEY) is None + assert hosted.runtime.channels["email"].can_accept() + + async def test_the_agent_cannot_post_through_the_email_channel(self, hosted): + verdict = await hosted.runtime.channels["email"].authorize_outbound(CONVERSATION) + + assert not verdict.allowed + assert verdict.reason == "the email channel does not post to a conversation that the agent names" + + +# ---------------------------------------------------------------------- # +# The subscriptions frame over real streams # +# ---------------------------------------------------------------------- # + + +@pytest.mark.asyncio +class TestSubscriptionsFrame: + async def test_without_a_hosted_source_the_frame_is_the_contract_sample(self, hosted): + assert hosted.runtime.sources == {} + await hosted.stream() + + subscriptions = await self.first(hosted) + + assert subscriptions == FRAME["payload"]["subscriptions"] + envelope = Envelope( + version="1", kind="subscriptions", request_id=FRAME["request_id"], + payload=Payload(subscriptions=hosted.runtime.subscriptions()), + ) + assert json.loads(encode_envelope(envelope)) == FRAME + + async def test_while_email_is_off_the_frame_leaves_it_out(self, hosted): + hosted.config.email.enabled = False + await hosted.stream() + + assert await self.first(hosted) == {"version": "1"} + + async def test_a_reload_switches_email_on_and_off_at_the_gateway(self, hosted): + hosted.config.email.enabled = False + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + assert await self.first(hosted) == {"version": "1"} + engine = republisher(hosted.runtime) + + hosted.config.email.enabled = True + assert await config_reload._republish_hosted_subscriptions(engine) == "published on 1 stream(s)" + await hosted.gateway.wait_for(lambda: len(hosted.gateway.subscriptions) == 2) + assert hosted.gateway.subscriptions[-1] == FRAME["payload"]["subscriptions"] + await turn_of(hosted, stream, mail(1), StreamingTurns()) + + hosted.config.email.enabled = False + assert await config_reload._republish_hosted_subscriptions(engine) == "published on 1 stream(s)" + await hosted.gateway.wait_for(lambda: len(hosted.gateway.subscriptions) == 3) + assert hosted.gateway.subscriptions[-1] == {"version": "1"} + refused = hosted.gateway.store(mail(2)) + await stream.nudge("invoke") + assert (await hosted.acknowledged(refused))[refused] == "rejected" + + async def test_a_switch_off_without_a_stream_is_not_sent_yet_and_reaches_the_next_stream(self, hosted): + """The gateway keeps the email entry until a stream sends the frame, + so the mail that it stores in the gap is rejected when Nerve reads it, + as the switch asks.""" + first = await hosted.stream(advertise=False) + assert await self.first(hosted) == FRAME["payload"]["subscriptions"] + await first.close() + await hosted.gateway.wait_for(lambda: not any(s.ready for s in hosted.runtime.streams.streams)) + hosted.config.email.enabled = False + + outcome = await config_reload._republish_hosted_subscriptions(republisher(hosted.runtime)) + + assert outcome == NOT_SENT + assert config_reload.reload_failures({"subscriptions": outcome}) == {} + stored = hosted.gateway.store(mail()) + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + await stream.nudge("invoke") + assert (await hosted.acknowledged(stored))[stored] == "rejected" + assert hosted.gateway.subscriptions[-1] == {"version": "1"} + assert sent_first(stream) == {"version": "1"} + + async def test_a_switch_on_without_a_stream_is_not_sent_yet_and_reaches_the_next_stream(self, hosted): + hosted.config.email.enabled = False + hosted.config.email.enabled = True + + outcome = await config_reload._republish_hosted_subscriptions(republisher(hosted.runtime)) + + assert outcome == NOT_SENT + stream = await hosted.stream(advertise=False) + await announce(hosted, stream) + await turn_of(hosted, stream, mail(), StreamingTurns()) + assert sent_first(stream) == FRAME["payload"]["subscriptions"] + + async def test_the_email_entry_follows_the_github_sources(self, tmp_path, db, monkeypatch): + harness = await start(tmp_path, db, monkeypatch) + try: + harness.config.email.enabled = True + assert set(harness.runtime.channels) == {"email"} + await harness.stream() + + [subscriptions] = await harness.subscriptions() + + assert [source["provider"] for source in subscriptions["sources"]] == ["github", "email"] + assert subscriptions["sources"][-1] == {"provider": "email", "filters": {}} + finally: + await stop(harness) + + @staticmethod + async def first(hosted: Hosted) -> dict[str, Any]: + await hosted.gateway.wait_for(lambda: bool(hosted.gateway.subscriptions)) + return hosted.gateway.subscriptions[0] + + +# What a reload reports when no gateway stream is open: a note, not an error, +# because every stream that connects sends the current subscriptions first. +NOT_SENT = ( + "not sent yet: no gateway stream is open; the gateway gets them when a stream " + "connects, before Nerve reads from it" +) + + +def sent_first(stream) -> dict[str, Any]: + """The subscriptions that *stream* sent, which come before its first read.""" + kinds_sent = [frame["kind"] for frame in stream.frames if frame["kind"] != "heartbeat"] + assert kinds_sent[:2] == ["negotiation", "subscriptions"] + assert kinds_sent.index("subscriptions") < kinds_sent.index("inbox_read") + [frame] = stream.sent("subscriptions") + return frame["payload"]["subscriptions"] + + +# ---------------------------------------------------------------------- # +# The reload report # +# ---------------------------------------------------------------------- # + + +async def reload_to(monkeypatch, tmp_path, new: NerveConfig, runtime=None) -> dict[str, Any]: + """Run a whole reload from *new* with email off to *new*, with *runtime* as + the hosted runtime.""" + import nerve.config as config_module + + old = copy.deepcopy(new) + old.email.enabled = False + monkeypatch.setattr(config_module, "_config", old) + monkeypatch.setattr(config_module, "load_config", lambda config_dir=None: new) + engine = SimpleNamespace( + get_channel_runtime={"hosted": runtime}.get if runtime is not None else {}.get, + reload_mcp_config=AsyncMock(return_value=[]), + ) + return await config_reload.reload_all(engine, None, tmp_path) + + +@pytest.mark.asyncio +class TestReloadReport: + async def test_email_without_usable_hosted_settings_is_a_warning(self, monkeypatch, tmp_path): + new = NerveConfig.from_dict({"email": {"enabled": True}}) + + summary = await reload_to(monkeypatch, tmp_path, new) + + assert summary["email"] == email_warning(new) + assert "restart_required" not in summary + assert config_reload.reload_failures(summary) == {} + + async def test_usable_settings_without_an_email_channel_need_a_restart(self, monkeypatch, tmp_path): + summary = await reload_to(monkeypatch, tmp_path, hosted_config(enabled=True)) + + assert summary["email"] == ( + "email.enabled is set and the channels.hosted settings are usable, but this " + "process has no email channel: it is built at startup, so restart Nerve" + ) + assert "email.enabled: the email channel starts at the next restart" in summary["restart_required"] + + async def test_a_running_email_channel_or_a_switched_off_email_says_nothing(self, hosted, monkeypatch, tmp_path): + on = copy.deepcopy(hosted.config) + on.email.enabled = True + off = copy.deepcopy(on) + off.email.enabled = False + + for new in (on, off): + summary = await reload_to(monkeypatch, tmp_path, new, hosted.runtime) + + assert "email" not in summary + assert "email.enabled" not in summary.get("restart_required", "") + # No stream is open: the subscriptions are a note, and the reload is ok. + assert summary["subscriptions"] == NOT_SENT + assert config_reload.reload_failures(summary) == {} + + +# ---------------------------------------------------------------------- # +# nerve doctor # +# ---------------------------------------------------------------------- # + + +def doctor_config(tmp_path: Path, **settings: Any) -> NerveConfig: + gateway = FakeChannelGateway(jwks_file=tmp_path / "gateway-jwks.json") + return NerveConfig.from_dict({ + "email": {"enabled": True}, + "channels": {"hosted": {**HOSTED, "gateway_jwks_file": str(gateway.jwks_file), **settings}}, + }) + + +class TestDoctor: + def test_email_from_the_gateway_is_reported(self, tmp_path): + report = doctor_report(doctor_config(tmp_path)) + + assert "[OK] Email from members comes from the channel gateway; Nerve posts no answer\n" in report + + def test_a_switched_off_email_shows_nothing(self, tmp_path): + config = doctor_config(tmp_path) + config.email.enabled = False + + assert "Email" not in doctor_report(config) + + def test_unusable_hosted_settings_are_a_warning(self, tmp_path): + report = doctor_report(doctor_config(tmp_path, agent_id="")) + + assert "[WARN] email.enabled is set, but mail comes only through the channel gateway" in report + assert "channels.hosted.agent_id must be a lowercase UUID" in report + + def test_an_unusable_key_file_is_an_error(self, tmp_path): + report = doctor_report(doctor_config(tmp_path, gateway_jwks_file=str(tmp_path / "missing.json"))) + + assert "[ERR] Email is on but channels.hosted.gateway_jwks_file: cannot read" in report + + @pytest.mark.parametrize(("slack", "warned"), [ + ({"mode": "hosted", "enabled": False}, True), + ({"mode": "hosted"}, False), + ({"mode": "socket", "enabled": False}, False), + ]) + def test_a_switched_off_hosted_slack_is_said_to_hold_mail_back(self, tmp_path, slack, warned): + config = doctor_config(tmp_path) + config.slack = SlackConfig.from_dict(slack) + + report = doctor_report(config) + + assert ("so member mail waits there too" in report) is warned diff --git a/tests/test_hosted_sources.py b/tests/test_hosted_sources.py index 43dfc3afc..ef0de0949 100644 --- a/tests/test_hosted_sources.py +++ b/tests/test_hosted_sources.py @@ -32,8 +32,10 @@ SourceSubscription, Subscriptions, ) +from nerve.channels.hosted import providers as providers_module from nerve.channels.hosted.contract.model import MAX_SUBSCRIPTION_PATTERNS from nerve.channels.hosted.contract.wire import dumps +from nerve.channels.hosted.providers import SLACK from nerve.channels.hosted.runtime import ( HostedChannelRuntime, hosted_channel_providers, @@ -1096,7 +1098,7 @@ async def test_subscriptions_that_waited_through_a_stall_carry_the_settings_of_w async with stalled_through_negotiation(hosted) as stream: hosted.config.sync.github.deny_reasons = ["subscribed", "comment"] outcome = await asyncio.wait_for(hosted.runtime.republish_subscriptions(), timeout=3) - assert outcome == "published on 0 stream(s)" + assert outcome.startswith("not sent yet: none of the 1 open stream(s) sent them in time") await hosted.gateway.wait_for(lambda: bool(stream.sent("inbox_read"))) [frame] = stream.sent("subscriptions") @@ -1287,7 +1289,8 @@ class TestSharedStreams: async def test_slack_and_github_share_the_streams_and_the_reader(self, tmp_path, db, monkeypatch): harness = await start(tmp_path, db, monkeypatch, slack=True) try: - assert set(harness.runtime.channels) == {"slack"} + # The usable hosted settings give the email channel too. + assert set(harness.runtime.channels) == {"slack", "email"} assert set(harness.runtime.sources) == {"github", "github_repos"} assert harness.router.get_channel("github") is None stream = await harness.stream() @@ -1327,6 +1330,9 @@ async def test_a_switched_off_hosted_slack_holds_the_github_records_back(self, t await stop(harness) async def test_without_hosted_sources_no_subscriptions_are_sent(self, tmp_path, db, monkeypatch): + """A runtime without an email channel, which the frame would name + (see test_hosted_email_channel).""" + monkeypatch.setattr(providers_module, "HOSTED_CHAT_PROVIDERS", (SLACK,)) harness = await start(tmp_path, db, monkeypatch, slack=True, github=False) try: stream = await harness.stream() @@ -1338,7 +1344,10 @@ async def test_without_hosted_sources_no_subscriptions_are_sent(self, tmp_path, finally: await stop(harness) - async def test_a_runtime_without_any_hosted_provider_is_refused(self, tmp_path, db): + async def test_a_runtime_without_any_hosted_provider_is_refused(self, tmp_path, db, monkeypatch): + """Usable hosted settings give the email channel, so only a table + without email leaves no provider.""" + monkeypatch.setattr(providers_module, "HOSTED_CHAT_PROVIDERS", (SLACK,)) config = slack_hosted_config(tmp_path) config.slack = SlackConfig()