hyperhive/nix/agent-modules/matrix.nix
atlas 99b141f5f2 matrix: drop the per-agent matrix.enable; accounts are the enable signal
`services.hyperhive.agent.matrix.enable` was a second source of truth for
a fact the account set already carried: after ①-③ the hive-internal
`main` account is an ordinary `matrixAccounts` entry, so "does this agent
have matrix" and "does this agent have an account" were the same question
asked twice, with the boolean able to disagree.

The option is gone and a non-empty `matrixAccounts` now gates the daemon
unit, its token path-watcher and the injected `extraMcpServers.matrix`
entry.

That is only a real condition because `matrixAccounts.main` is itself
gated: it is declared when `matrix.url != null`, never unconditionally. A
`main` with no homeserver is an account the daemon can never log in as,
so declaring one always would have made the signal trivially true and
turned matrix on for every agent in every hive. With the URL gate, the
empty set is reachable exactly for an agent the hive gave no homeserver
and whose operator declared no account of its own — the state the old
`enable = false` expressed.

Assertions: "extras require enable" is deleted, having become the
definition of the thing it checked (an external-only account with its own
homeserver is now rendered rather than rejected). `main.tokenFile` stays
pinned, re-guarded on `? main` instead of on the flag, since `main` is
absent whenever the URL is null and an unguarded index would throw there.

Both spellings of the option get `mkRemovedOptionModule`, following
../host-modules/deploy.nix's registrationTokenFile pair rather than a
silent delete: the definition whose meaning changes is `false`, and left
undeclared it would be ignored and hand the agent the tools its operator
turned off. Failing the eval with the replacement spelling is the only
outcome that cannot.

module-eval gains the three arms — URL, nothing, external-only — with the
middle one carrying why it exists: it is the only thing in the suite that
would notice `main` becoming unconditional again.

Refs #4475
2026-09-18 10:35:16 +02:00

396 lines
20 KiB
Nix

# Per-agent matrix integration: the `services.hyperhive.agent.matrix.*` +
# `services.hyperhive.agent.matrixAccounts` options, the long-running
# hive-matrix-daemon (serves its MCP tools directly over
# streamable-http), its token-arrival path trigger, and the
# auto-injected extraMcpServers entry.
#
# There is no `matrix.enable`. An agent has matrix exactly when it has an
# account to serve — see `matrixEnabled` below.
{
pkgs,
lib,
config,
...
}:
let
userName = config.services.hyperhive.agent.user.name;
# This agent's own state dir, where hive-c0re provisions the
# hive-internal account's token (`matrix-token`) and the daemon keeps
# its matrix-sdk store. Shared by the `main` account entry below and
# the path-watcher glob at the bottom of this file.
stateDir = "/agents/${userName}/state";
accounts = config.services.hyperhive.agent.matrixAccounts;
# **The enable signal.** Matrix is on for this agent exactly when it has at
# least one account, because an account is the only thing the daemon has to
# do: no account, no Client, no sync, no tool surface worth injecting.
#
# This is not trivially true even though the module declares `main` itself:
# that definition is gated on `matrix.url != null` (see below), which is the
# per-agent "does this agent have a homeserver to reach" fact. So an agent the
# hive handed no homeserver URL, whose operator declared no external account
# either, has an empty set here and gets none of the units — the case the
# deleted `matrix.enable = false` used to express.
#
# No cycle: `matrixAccounts`'s own definition reads `matrix.url`, never this
# binding, so the `mkIf`s below may read the merged option value freely.
matrixEnabled = accounts != { };
# Rasterize the operator-set agent icon (`services.hyperhive.agent.icon`, an SVG) to a
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
# Only forced when an icon is configured — the `HIVE_ICON_PNG` daemon-env
# entry is gated on `services.hyperhive.agent.icon != null`, so this binding stays lazy
# when no icon is set.
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.services.hyperhive.agent.icon} -o $out
'';
in
{
options.services.hyperhive.agent.matrix.url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://matrix.darkest.space";
description = ''
Matrix homeserver URL the agent's `hive-matrix-daemon` connects
to. hive-c0re writes this per agent from the hive's own
isolation-aware URL (`gatewayHost`'s vhost via the gateway,
`chat.<swarm-domain>` by default), so a
generated agent config always carries a real value; set it by
hand only when an agent should talk to an external homeserver
instead (a federation-only setup, or a remote hive's tuwunel
reached over a vpn).
**`null` means "no matrix", not "guess one".** There is
deliberately no loopback default: the homeserver may run on a
different host from the agents, and inside an agent's network
namespace `localhost` reaches the agent rather than the
homeserver, so a default would be a value that builds fine and
then talks to the wrong machine. An absent integration, never a
misdirected one.
`null` is also this agent's **matrix off switch**, and the
replacement for the `services.hyperhive.agent.matrix.enable`
boolean that used to exist: the hive-internal `main` account in
`services.hyperhive.agent.matrixAccounts` is declared from this
URL, so `null` leaves that set empty and the whole integration
daemon unit, path watcher, injected MCP entry is not generated
at all. Declaring an external account with its own `homeserver`
turns matrix back on without a hive homeserver, which is the
honest reading of that config.
'';
};
options.services.hyperhive.agent.matrixAccounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
tokenFile = lib.mkOption {
type = lib.types.str;
example = "/agents/dmatrix/state/matrix-token-ccc";
description = ''
Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern
(hive-c0re for the hive-internal `main` account, an
operator-supplied secret for an external one). The daemon
skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is.
'';
};
sessionDir = lib.mkOption {
type = lib.types.str;
example = "/agents/dmatrix/state/matrix-sdk-state-ccc";
description = ''
Per-account matrix-sdk sqlite store directory (crypto keys
+ event cache). Must differ between accounts so their
sessions do not collide.
'';
};
homeserver = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://matrix.example.org";
description = ''
Homeserver URL for this account. When null (the default),
the account falls back to `services.hyperhive.agent.matrix.url`. Set it for
an account on a different homeserver than the agent's
default (e.g. an external public-matrix account).
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
ccc = {
tokenFile = "/agents/dmatrix/state/matrix-token-ccc";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc";
homeserver = "https://matrix.example.org";
};
}
'';
description = ''
Every matrix account served by the single `hive-matrix-daemon`
(one matrix-sdk Client + sync loop each). The attribute name keys
each account (unique by construction) and is the handle the matrix
MCP tools target via their `account` argument.
**This set is also the enable signal for per-agent matrix**
there is no separate boolean. A non-empty set means the harness:
- runs `hive-matrix-daemon` as a systemd unit that holds a
matrix-sdk Client + sync per account against that account's
homeserver (`homeserver`, else
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
account whose URL or token file is missing, and a `systemd.paths`
watcher restarts it the moment hive-c0re provisions the token
(same path-trigger shape as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms,
list_room_members, read_room) to claude via an auto-injected
`extraMcpServers.matrix` entry pointed at the daemon's own
streamable-http listener (`services.hyperhive.agent.mcp.matrixHttpPort`) no
stdio bridge, no per-turn respawn, same shape as the built-in
hyperhive surface and `hive-bash-daemon`.
- wakes the agent on incoming room events via a short teaser
Wake signal (`[matrix] <sender> in <room>: <first 100c>`)
to the hyperhive control socket; the full event stays
unread server-side until `read_room` consumes it.
An **empty** set is an agent with no matrix at all: none of those
three exist. That is the state an agent reaches by having no
homeserver (`services.hyperhive.agent.matrix.url = null`) and no
account of its own, and it replaces the removed
`services.hyperhive.agent.matrix.enable = false`.
The **hive-internal account is the primary whenever it exists**: it
is named `main`, and this module declares it for you from
`services.hyperhive.agent.matrix.url` + `<state>/matrix-token` +
`<state>/matrix-sdk-state` so it is present exactly when that URL
is non-null, which on a real hive is always (hive-c0re renders it
per agent). It is the account a tool call acts as when it omits
`account`. It is an ordinary entry of this option
like any other, so it shows up in the account list --- what you
add here are the *further* accounts (e.g. an external
public-matrix account). Its `tokenFile` stays pinned to
`<state>/matrix-token` (an assertion; that is the one path
hive-c0re provisions the hive-internal token to), and the
dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner.
Leave it alone (the default) for the common single-account case:
the agent then has only `main`. The whole set is serialized to the
daemon's `HIVE_MATRIX_ACCOUNTS` environment variable, `main`
first.
'';
};
options.services.hyperhive.agent.mcp.matrixHttpPort = lib.mkOption {
type = lib.types.port;
default = 8792;
example = 8793;
description = ''
Loopback port `hive-matrix-daemon` serves its MCP tools
(`send_message`, `list_rooms`, `read_room`, ) on. Same shape as
`services.hyperhive.agent.mcp.bashHttpPort`: HTTP is the *sole* transport (no
stdio bridge the daemon that owns the matrix-sdk `Client`
registry serves the MCP tools directly in-process),
`Restart = "always"` keeps the listener self-healing, and
loopback-only binding means no auth token is needed (same
`allowed_hosts` reasoning as `services.hyperhive.agent.mcp.httpPort`). Safe as a
single fixed default across all agents (private per-container
network namespace see docs/networking/network.md).
'';
};
config = {
assertions = [
# The "extras require `matrix.enable`" assertion that used to head this
# list is gone with the option: a non-empty account set is now what
# enables matrix, so the condition it checked has become the definition
# of the thing it was checking. An operator declaring only an external
# account, with no hive homeserver, is a config this module now renders
# rather than rejects — matrix on, no `main`.
#
# `main` is not a forbidden key --- this module declares it
# itself (see the `matrixAccounts.main` definition below), so the
# name must be allowed. What stays rejected is retargeting *its
# token file*: hive-c0re writes the hive-internal account's token
# to `<state>/matrix-token` and nowhere else, so an override there
# is an account that evaluates fine and then never restores. The
# other two fields are free to override (a `mkDefault` each).
#
# Guarded on `? main` rather than on an enable flag: `main` is absent
# whenever `matrix.url` is null, and an unguarded `.main.tokenFile`
# would throw on exactly those agents instead of passing vacuously.
{
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
message =
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
+ "hive-internal account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere.";
}
# Token files must land at the `matrix-token*` name the daemon
# path-watcher globs (`matrix-token*` inside this agent's own state
# dir), or the account never gets picked up live (it loads only on a
# full daemon restart).
# Enforce the basename prefix so a deviating name (e.g.
# `matrix-catgirl-token`) is caught at build time, not silently.
{
assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) (
lib.attrValues accounts
);
message =
"every services.hyperhive.agent.matrixAccounts.<name>.tokenFile basename must start with "
+ "\"matrix-token\" so the daemon path-watcher glob "
+ "(matrix-token* in the agent's state dir) picks it up live. Offending: "
+ lib.concatStringsSep ", " (
lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") (
lib.filterAttrs (_n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) accounts
)
)
+ ".";
}
];
# The hive-internal account as an ordinary `matrixAccounts` entry,
# rather than something the daemon conjures behind the option's
# back: `matrixAccounts` is the list of *all* this agent's accounts,
# so the one it always has belongs in it. `mkDefault` per field so an
# operator can retarget e.g. the homeserver without a
# conflicting-definition error (the token file is pinned by an
# assertion above, since hive-c0re owns that path).
#
# ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled`
# from being trivially true for every agent in the hive. A `main` with no
# homeserver is an account the daemon can never log in as, so declaring one
# unconditionally would enable matrix everywhere and inject a tool surface
# backed by a permanently no-opping daemon. On a real hive hive-c0re renders
# this URL per agent (meta.rs's `FORWARDED_VAR_OPTIONS`), so the common case
# is still "every agent has `main`".
services.hyperhive.agent.matrixAccounts =
lib.mkIf (config.services.hyperhive.agent.matrix.url != null)
{
main = {
tokenFile = lib.mkDefault "${stateDir}/matrix-token";
sessionDir = lib.mkDefault "${stateDir}/matrix-sdk-state";
homeserver = lib.mkDefault config.services.hyperhive.agent.matrix.url;
};
};
# Auto-inject the matrix MCP entry alongside the bash entry from
# ./mcp.nix. `lib.mkDefault` so the operator's own agent.nix can
# override it. Points at the daemon's own persistent
# streamable-http listener — no stdio bridge, no per-turn spawn.
services.hyperhive.agent.extraMcpServers = lib.mkIf matrixEnabled {
matrix = lib.mkDefault {
type = "http";
url = "http://127.0.0.1:${toString config.services.hyperhive.agent.mcp.matrixHttpPort}/mcp";
allowedTools = [ "*" ];
};
};
# Long-running matrix-sdk client + sync per agent. Serves the MCP
# tools directly over streamable-http + emits hyperhive wake
# signals on incoming room events via `/run/hive/mcp.sock`. See
# `docs/agent-lifecycle/persistence.md::Matrix per-agent daemon + token-arrival
# trigger` for the first-boot-ordering rationale.
systemd.services.hive-matrix-daemon = lib.mkIf matrixEnabled {
description = "long-running matrix-sdk Client + MCP daemon";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
# In-agent todo socket the harness serves (loose-ends v2): the
# matrix sweep pushes unread-room + pending-invite todos here
# instead of firing wakes at hive-c0re's mcp.sock.
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
}
# Homeserver URL. hive-c0re writes this option per agent from the
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
# by default — agents run in a private
# netns and cannot reach host loopback), so on a real hive it is
# always set; `null` is the honest "this agent has no homeserver"
# and leaves the daemon without one, which it treats like a missing
# token and no-ops. Nothing here falls back to loopback: that would
# be a value that evaluates fine and then addresses the agent's own
# netns instead of the homeserver.
// lib.optionalAttrs (config.services.hyperhive.agent.matrix.url != null) {
HIVE_MATRIX_URL = config.services.hyperhive.agent.matrix.url;
}
# Serialize the whole account set --- `main` included --- to the
# JSON the daemon parses (`accounts::configured`). Each entry is in
# the daemon's `AccountCfg` serde shape: name (the attr key) /
# token_file / state_dir / optional homeserver. The daemon hoists
# `main` to primary wherever the attr key sorted, and falls back to
# synthesizing it from the per-agent paths only when this JSON
# carries no `main` --- which is how an agent whose harness
# predates this entry keeps working.
#
# Unconditional, not `optionalAttrs (accounts != {})`: a non-empty set is
# what generated this unit at all, so the guard could only ever be true.
// {
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
lib.mapAttrsToList (
name: a:
{
inherit name;
token_file = a.tokenFile;
state_dir = a.sessionDir;
}
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) accounts
);
}
# Rasterized agent icon path for the daemon's avatar sync. Only set
# when an icon is configured; absent → the daemon skips avatar setting
# (hive-matrix-mcp::client::sync_avatar returns early on unset env).
// lib.optionalAttrs (config.services.hyperhive.agent.icon != null) {
HIVE_ICON_PNG = "${iconPng}";
};
serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-matrix-daemon}/bin/hive-matrix-daemon --http 127.0.0.1:${toString config.services.hyperhive.agent.mcp.matrixHttpPort}";
SyslogIdentifier = "hive-matrix-daemon";
# `on-failure`, not `always`: the daemon deliberately exits 0
# (a clean, non-failure exit) when no token is provisioned yet
# (see the module doc above) — the `systemd.paths` watcher
# below re-fires it the moment hive-c0re provisions one,
# instead of `always` busy-looping every `RestartSec` until
# then. Once a token exists this is no different from
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
# tools with no stdio fallback) — a genuine crash is a
# non-zero exit, which `on-failure` already restarts.
Restart = "on-failure";
RestartSec = 5;
User = userName;
Group = userName;
};
};
# Re-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See
# `docs/agent-lifecycle/persistence.md` (same section as above).
systemd.paths.hive-matrix-daemon = lib.mkIf matrixEnabled {
description = "trigger hive-matrix-daemon when a matrix token appears";
wantedBy = [ "multi-user.target" ];
# `matrix-token*` (not just `matrix-token`) so a secondary
# multi-account token (e.g. `matrix-token-ccc`) landing also
# re-fires the daemon to pick up the freshly-provisioned account.
#
# ⚠️ This agent's own state dir, not a glob over `/agents/*/`. Every
# agent's state dir is visible from inside every container, so a
# wildcard is satisfied by a sibling's token — and this daemon exits 0
# when it has no token of its own (see `Restart = "on-failure"`
# above), so the unit deactivates, the path unit re-arms, the
# sibling's token still matches, and it fires again until the start
# limit stops it. Scoped to this agent, the condition is false exactly
# when the daemon would have nothing to do.
pathConfig.PathExistsGlob = "${stateDir}/matrix-token*";
};
};
}