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
This commit is contained in:
atlas 2026-09-18 09:42:23 +02:00
commit 99b141f5f2
7 changed files with 254 additions and 92 deletions

View file

@ -18,6 +18,25 @@
flakeInputs ? { },
...
}:
let
# What an `agent.nix` still setting the dropped per-agent matrix switch is
# told. Shared by the two `mkRemovedOptionModule` entries below, one per
# spelling of the path.
matrixEnableRemoved = ''
The per-agent matrix switch is gone. Matrix is enabled for an agent exactly
when it has at least one services.hyperhive.agent.matrixAccounts entry, and
the hive-internal `main` entry is declared for you whenever
services.hyperhive.agent.matrix.url is non-null.
- `enable = true` was the default: drop the line, nothing else to do.
- `enable = false`: set services.hyperhive.agent.matrix.url = null instead
(and declare no accounts of your own). That is what leaves the account set
empty, which is now what suppresses the daemon, its path watcher and the
injected extraMcpServers.matrix entry.
See docs/tools/matrix.md.
'';
in
{
imports = [
./agent-service.nix
@ -47,6 +66,20 @@
The built-in Bash tool is fully disabled; agents use
mcp__bash__run instead. Remove the setting from your agent.nix.
'')
# The dropped per-agent matrix switch. A removal, not a silent delete,
# because the definition that *changes meaning* is `false`: left undeclared
# it would be ignored and the agent would quietly gain the matrix tools its
# operator turned off. Failing the eval with the replacement spelling is the
# only outcome that cannot do that.
#
# Both spellings, for the same reason ../host-modules/deploy.nix lists both
# of `swarm|deploy.matrix.registrationTokenFile`: an existing agent.nix may
# carry either the pre-rename `hyperhive.*` path or the current one, and only
# the one it actually carries will fire. The rename shim that used to bridge
# them is gone from ./renamed-options.nix — a rename pointing at an
# undeclared option breaks evaluation for everyone, removed or not.
(lib.mkRemovedOptionModule [ "hyperhive" "matrix" "enable" ] matrixEnableRemoved)
(lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "matrix" "enable" ] matrixEnableRemoved)
];
options.services.hyperhive.agent.icon = lib.mkOption {

View file

@ -3,6 +3,9 @@
# 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,
@ -16,6 +19,21 @@ let
# 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).
@ -27,40 +45,6 @@ let
'';
in
{
options.services.hyperhive.agent.matrix.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable per-agent matrix integration via `hive-matrix-daemon`.
When true (the default), the harness:
- runs `hive-matrix-daemon` as a systemd unit that holds a
matrix-sdk Client + sync against the homeserver named by
`HIVE_MATRIX_URL` (see `services.hyperhive.agent.matrix.url` there is no
default, since an agent's own netns makes a loopback guess
wrong). The daemon auto-skips when that URL or
`<state>/matrix-token` 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.
Set to `false` for agents that should NOT have matrix tools at
all (e.g. agents on a host without `services.hyperhive.agent.matrix.enable` on
the meta side). When token file is absent the daemon and MCP
both no-op cleanly anyway, so `false` is rarely necessary.
'';
};
options.services.hyperhive.agent.matrix.url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -80,10 +64,18 @@ in
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. When this is `null` the daemon
is left without a homeserver and no-ops, exactly as it does when
the token file is absent --- an absent integration, never a
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.
'';
};
@ -143,12 +135,41 @@ in
each account (unique by construction) and is the handle the matrix
MCP tools target via their `account` argument.
The **hive-internal account is always present and is the primary**:
it is named `main`, and this module declares it for you (when
`services.hyperhive.agent.matrix.enable` is set) from
**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`. It is the account a tool call acts as
when it omits `account`. It is an ordinary entry of this option
`<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
@ -160,8 +181,7 @@ in
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. Declaring extras requires
`services.hyperhive.agent.matrix.enable` (there is no `main` to extend otherwise).
first.
'';
};
@ -185,29 +205,26 @@ in
config = {
assertions = [
# Extra matrix accounts only make sense alongside the hive-internal
# `main` account they extend, which exists only when matrix is
# enabled.
{
assertion =
config.services.hyperhive.agent.matrixAccounts == { }
|| config.services.hyperhive.agent.matrix.enable;
message =
"services.hyperhive.agent.matrixAccounts requires services.hyperhive.agent.matrix.enable = true "
+ "(the extras extend the hive-internal `main` account, which only "
+ "exists when matrix is enabled).";
}
# `main` is no longer a forbidden key --- this module declares it
# 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 =
!config.services.hyperhive.agent.matrix.enable
|| config.services.hyperhive.agent.matrixAccounts.main.tokenFile == "${stateDir}/matrix-token";
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 "
@ -222,7 +239,7 @@ in
# `matrix-catgirl-token`) is caught at build time, not silently.
{
assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) (
lib.attrValues config.services.hyperhive.agent.matrixAccounts
lib.attrValues accounts
);
message =
"every services.hyperhive.agent.matrixAccounts.<name>.tokenFile basename must start with "
@ -230,9 +247,7 @@ in
+ "(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)
) config.services.hyperhive.agent.matrixAccounts
lib.filterAttrs (_n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) accounts
)
)
+ ".";
@ -246,19 +261,29 @@ in
# 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).
services.hyperhive.agent.matrixAccounts = lib.mkIf config.services.hyperhive.agent.matrix.enable {
main = {
tokenFile = lib.mkDefault "${stateDir}/matrix-token";
sessionDir = lib.mkDefault "${stateDir}/matrix-sdk-state";
homeserver = lib.mkDefault config.services.hyperhive.agent.matrix.url;
};
};
#
# ⚠️ 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 config.services.hyperhive.agent.matrix.enable {
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";
@ -271,7 +296,7 @@ in
# 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 config.services.hyperhive.agent.matrix.enable {
systemd.services.hive-matrix-daemon = lib.mkIf matrixEnabled {
description = "long-running matrix-sdk Client + MCP daemon";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
@ -304,7 +329,10 @@ in
# 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.
// lib.optionalAttrs (config.services.hyperhive.agent.matrixAccounts != { }) {
#
# 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:
@ -314,7 +342,7 @@ in
state_dir = a.sessionDir;
}
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) config.services.hyperhive.agent.matrixAccounts
) accounts
);
}
# Rasterized agent icon path for the daemon's avatar sync. Only set
@ -347,7 +375,7 @@ in
# 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 config.services.hyperhive.agent.matrix.enable {
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

View file

@ -273,7 +273,8 @@ in
# Auto-inject the built-in bash MCP server — always present, every
# agent needs bash tools. `lib.mkDefault` so the operator's own
# agent.nix can override the entry. (The matrix sibling lives in
# ./matrix.nix, gated on services.hyperhive.agent.matrix.enable.) `hive-bash-daemon`
# ./matrix.nix, gated on that agent having a
# services.hyperhive.agent.matrixAccounts entry at all.) `hive-bash-daemon`
# serves its MCP tools directly over streamable-http (no stdio bridge,
# no round-trip socket) — see the `hive-bash-daemon` service below.
services.hyperhive.agent.extraMcpServers.bash = lib.mkDefault {
@ -284,8 +285,9 @@ in
# Auto-inject the subagent MCP server — default-on for every agent for
# now (operator's call: "default on for now, should be a capability
# later" — not gated behind an enable option yet, unlike `matrix.nix`'s
# pattern). `lib.mkDefault` so an agent.nix can still override/disable
# later" — unconditional, where `matrix.nix`'s sibling entry appears only
# for an agent that has a matrix account to serve).
# `lib.mkDefault` so an agent.nix can still override/disable
# the entry in the meantime.
services.hyperhive.agent.extraMcpServers.subagent = lib.mkDefault {
type = "http";

View file

@ -119,10 +119,10 @@
[ "hyperhive" "logs" "queryUrl" ]
[ "services" "hyperhive" "agent" "logs" "queryUrl" ]
)
(lib.mkRenamedOptionModule
[ "hyperhive" "matrix" "enable" ]
[ "services" "hyperhive" "agent" "matrix" "enable" ]
)
# `matrix.enable` is deliberately absent from this list: the option it
# renamed to no longer exists, and a rename pointing at an undeclared
# option breaks evaluation for everyone. Both of its spellings are
# `mkRemovedOptionModule` in ./default.nix instead.
(lib.mkRenamedOptionModule
[ "hyperhive" "matrix" "url" ]
[ "services" "hyperhive" "agent" "matrix" "url" ]

View file

@ -611,6 +611,29 @@ let
# RAM percentage and so hands the module no byte count to size against.
agentCapped = agent { claudeMemoryMaxBytes = 8589934592; };
agentUncapped = agent { };
# Matrix's enable signal, which is the account set itself — there is no
# `matrix.enable` option left to read. Three arms, because the property has
# three distinct shapes and only one of them is the common case:
#
# - a homeserver URL, which is what the module turns into a `main` account;
# - neither URL nor operator account, the state that replaced
# `matrix.enable = false`. ⚠️ **This is the arm that matters.** `main` is
# declared by the module itself, so "any account declared" would be
# trivially true — and matrix would render for every agent in every hive —
# the moment that declaration stops being gated on the URL. Nothing else in
# this suite would notice;
# - an operator account carrying its own homeserver and no hive one, which is
# matrix on with no `main` at all.
agentMatrix = agent { matrix.url = "https://chat.t.local"; };
agentNoMatrix = agent { };
agentMatrixExternalOnly = agent {
matrixAccounts.ccc = {
tokenFile = "/agents/a1/state/matrix-token-ccc";
sessionDir = "/agents/a1/state/matrix-sdk-state-ccc";
homeserver = "https://matrix.example.invalid";
};
};
agentHarness = machine: machine.systemd.services.hive-agent;
agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon;
agentSettings = machine: machine.services.opentelemetry-collector.settings;
@ -1793,6 +1816,62 @@ let
name = "one subagent losing the OOM draw does not stop the daemon";
ok = (agentSubagentDaemon agentUncapped).serviceConfig.OOMPolicy == "continue";
}
{
# A homeserver URL is the whole input: from it the module derives the
# hive-internal `main` account, and from a non-empty account set the three
# things that used to hang off `matrix.enable`.
name = "an agent with a homeserver gets a main account and the matrix units";
ok =
let
a = agentMatrix.services.hyperhive.agent.matrixAccounts;
in
lib.attrNames a == [ "main" ]
&& a.main.tokenFile == "/agents/a1/state/matrix-token"
&& a.main.homeserver == "https://chat.t.local"
&& agentMatrix.systemd.services ? hive-matrix-daemon
&& agentMatrix.systemd.paths ? hive-matrix-daemon
&& agentMatrix.services.hyperhive.agent.extraMcpServers ? matrix;
}
{
# The absence arm, and the reason the enable signal is not vacuous. An
# agent the hive gave no homeserver, whose operator declared nothing, must
# come out with an EMPTY account set — not a `main` that can never log in
# — and therefore with none of the three. Assert the emptiness itself and
# not just the units: it is the account set that is load-bearing now, and
# a `main` sneaking back in is the regression this case exists to name.
name = "an agent with no homeserver and no declared account gets no matrix at all";
ok =
agentNoMatrix.services.hyperhive.agent.matrixAccounts == { }
&& !(agentNoMatrix.systemd.services ? hive-matrix-daemon)
&& !(agentNoMatrix.systemd.paths ? hive-matrix-daemon)
&& !(agentNoMatrix.services.hyperhive.agent.extraMcpServers ? matrix);
}
{
# Matrix without a hive homeserver: one operator account, its own
# homeserver, no `main`. Under the deleted `matrix.enable` this config was
# an assertion failure ("extras require enable") even though every account
# in it was complete; the account set being the signal is what makes it
# expressible, and the serialized env var is where that has to show up.
name = "an external-only account enables matrix with no main entry";
ok =
let
accts = agentMatrixExternalOnly.services.hyperhive.agent.matrixAccounts;
env = agentMatrixExternalOnly.systemd.services.hive-matrix-daemon.environment;
in
lib.attrNames accts == [ "ccc" ]
&& !(accts ? main)
&&
builtins.fromJSON env.HIVE_MATRIX_ACCOUNTS == [
{
name = "ccc";
token_file = "/agents/a1/state/matrix-token-ccc";
state_dir = "/agents/a1/state/matrix-sdk-state-ccc";
homeserver = "https://matrix.example.invalid";
}
]
# No hive homeserver, so nothing may claim one.
&& !(env ? HIVE_MATRIX_URL);
}
{
# The doctrine three glue files state, as a property a rewrite has to
# keep: a client is defined by holding a certificate the store accepts,