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:
parent
e95e988965
commit
99b141f5f2
7 changed files with 254 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue