Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/matrix.nix
atlas 97fb76ce99 matrix: the agent's daemon pulls its linked accounts from bao itself
hive-matrix-daemon now learns which external matrix accounts it has from
the swarm secret store, under the agent's own certificate, and the hive
push chain for matrix is gone.

The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy
grants on its own metadata subtree), reads each account's homeserver
from its credential, and brings the accounts up with their tokens from
the store. Every two minutes it lists again and exits with 75 when the
set of linked accounts changed; the unit restarts on 75 without counting
a failure. A listed name whose credential reads as absent is skipped and
logged once. At start it removes the matrix-token-<a> /
matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair
as delivered; a declared tokenFile keeps its token).

Removed: CredentialNotice and the $SWARM.credential.* subject and NATS
grant, the controller's publish and its queue precondition on the PUT
route, hive-c0re's credential subscription arm and workers/credential.rs,
priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken
and its helpers, and the daemon's state-dir account discovery.

Kept: WriteAgentGithubToken and the external-forge path
(WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a
declared matrixAccounts tokenFile is still read when the store has no
token for that account.

Refs #4348
2026-10-01 17:43:28 +02:00

445 lines
22 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: `main`'s fallback token file (`matrix-token`)
# and the daemon's matrix-sdk stores. 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;
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
# reads each account's token from the store itself, `main` included (the
# swarm mints that one), so the store's coordinates belong on its unit. The
# same three credential ids ./bao.nix and ./forge-token.nix load.
baoCfg = config.services.hyperhive.agent.bao;
storeConfigured = baoCfg.addr != null;
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
# **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
(for `main`, a file only when the store has no token: the
swarm mints `main` into the store; 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 a token file appears
(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; the file the daemon falls back
to when the store has no `main` token), 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*: the daemon's file fallback for `main` is
# `<state>/matrix-token` and nothing 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 the file the daemon falls back to for the "
+ "main 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 the daemon's fallback reads 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";
}
# The store's coordinates and which agent this is, so the daemon reads
# `swarm/agents/<agent>/matrix/<account>` as itself. The NAME and not a
# path: `swarm_secret_client` builds both the path and the cert-auth role
# from it. Paths only — `%d` is this unit's own credentials directory.
// lib.optionalAttrs storeConfigured {
HIVE_AGENT_NAME = userName;
BAO_ADDR = baoCfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
# Named even when no CA was delivered: a bare `LoadCredential=` is
# non-fatal when absent, and the daemon treats a missing or empty file
# as "use the container's own trust store".
BAO_CACERT = "%d/${serverCaCredential}";
}
# 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 a token file appears (the timer
# above, for a token in the store),
# 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;
# The daemon exits 75 (`ACCOUNTS_CHANGED_EXIT` in
# hive-matrix-mcp/src/main.rs) when the store's linked accounts change:
# a clean exit that must still restart it onto the new set.
RestartForceExitStatus = "75";
SuccessExitStatus = "75";
User = userName;
Group = userName;
}
# This agent's own certificate, imported by id so systemd materialises it
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
// lib.optionalAttrs storeConfigured {
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
};
# Re-start the daemon while it is down, when its token lives in the store.
# The path unit below only sees files, and a token the swarm mints or
# replaces in the store changes no file: a daemon that exited on a missing
# or replaced token would otherwise stay down until the container restarts.
# Relative to the daemon's last exit, so it never fires while the daemon
# runs; five minutes is the swarm's own re-mint cadence.
systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) {
description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store";
wantedBy = [ "timers.target" ];
timerConfig.OnUnitInactiveSec = "5min";
};
# Re-fire the daemon when a matrix token file appears (a declared
# `tokenFile`, or a `main` from before the swarm minted it). 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 declared secondary
# token file (e.g. `matrix-token-ccc`) landing also re-fires the daemon.
#
# ⚠️ 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*";
};
};
}