`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
396 lines
20 KiB
Nix
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*";
|
|
};
|
|
};
|
|
}
|