hive-matrix-mcp: read the main account's token from the store too

The daemon reads each account's token from `swarm/agents/<agent>/matrix/`
as the agent itself, inside its own container, and falls back to the file
only when the store has none. This is #4519's read, without its `main`
carve-out: the swarm now mints `main` there and no hive writes the file.

The daemon unit gets the agent's store identity, spelled the way
forge-token.nix spells it. A timer re-starts it while it is down: a token
the swarm mints or replaces in the store changes no file, so the path
watcher never fires for it, and a daemon that exited on a replaced token
would otherwise stay down until the container restarts.
This commit is contained in:
atlas 2026-09-25 01:57:25 +02:00 • committed by mara
commit ab153bda2f
9 changed files with 670 additions and 85 deletions

View file

@ -14,12 +14,21 @@
}:
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.
# This agent's own state dir, where a hive used to write the `main`
# account's token (`matrix-token`, now the store's fallback) 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;
# 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.
@ -90,8 +99,9 @@ in
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
(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.
'';
@ -143,7 +153,7 @@ in
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
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,
@ -173,8 +183,8 @@ in
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
`<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.
@ -215,8 +225,8 @@ in
# `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
# 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).
#
@ -227,8 +237,8 @@ in
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 "
+ "\"${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
@ -260,7 +270,7 @@ in
# 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).
# 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
@ -309,6 +319,20 @@ in
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
@ -357,7 +381,8 @@ in
# `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,
# 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
@ -367,11 +392,33 @@ in
RestartSec = 5;
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-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# 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 the matrix token appears (a token
# file: an extra account the hive delivers, 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).

View file

@ -18,6 +18,7 @@ let
;
})
agent
agentWith
runGroup
;
@ -45,6 +46,15 @@ let
homeserver = "https://matrix.example.invalid";
};
};
# The daemon that reads its tokens from the store, `main` included. Paired
# with `agentMatrix` above — same daemon, no store — so each case below can
# tell "carries the store's coordinates" from "every matrix daemon does".
agentMatrixBao = agentWith {
services.hyperhive.agent.bao.addr = "https://bao.t.local:8200";
services.hyperhive.agent.matrix.url = "https://chat.t.local";
};
daemon = machine: machine.systemd.services.hive-matrix-daemon;
cases = [
{
# A homeserver URL is the whole input: from it the module derives the
@ -102,6 +112,69 @@ let
# No hive homeserver, so nothing may claim one.
&& !(env ? HIVE_MATRIX_URL);
}
{
# The daemon reads this agent's tokens from the store ITSELF, as itself,
# from inside this container — `main` too, since the swarm mints it and
# no hive does. So it needs the same identity ./agent-forge-bao.nix's
# fetch carries, in its own credentials directory.
name = "the matrix daemon carries this agent's own store identity";
ok =
let
u = daemon agentMatrixBao;
in
u.serviceConfig.LoadCredential == [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
]
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# The agent's name, and nothing derived from it: the daemon builds its
# store path and its cert-auth role from this one string.
name = "the matrix daemon is told which agent it is and not where its credentials live";
ok =
let
e = (daemon agentMatrixBao).environment;
in
e.HIVE_AGENT_NAME == agentMatrixBao.services.hyperhive.agent.user.name
&& !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e));
}
{
# 🩸 A secret is a path: every `BAO_*` entry is the store's address or a
# file under this unit's own credentials directory, never bytes in an
# environment `/proc/<pid>/environ` publishes.
name = "the matrix daemon is handed store paths and never store values";
ok =
let
store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment;
in
store != { }
&& lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store);
}
{
# A token the swarm mints or replaces in the store changes no file, so
# the path watcher never sees it. The timer is what re-starts a daemon
# that exited on a missing or replaced token.
name = "a store-backed matrix daemon is re-started while it is down";
ok =
agentMatrixBao.systemd.timers.hive-matrix-daemon.timerConfig.OnUnitInactiveSec or null == "5min";
}
{
# The absence arm for the four above: with no store, no identity, no
# timer, and the file is the whole mechanism.
name = "a matrix daemon on an agent with no store declares no identity and no timer";
ok =
let
u = daemon agentMatrix;
in
!(u.serviceConfig ? LoadCredential)
&& !(u.environment ? BAO_ADDR)
&& !(u.environment ? HIVE_AGENT_NAME)
&& !(agentMatrix.systemd.timers ? hive-matrix-daemon);
}
];
in
runGroup "agent-matrix" cases