`hive-matrix-daemon.path` globbed `/agents/*/state/matrix-token*`. Every agent's state dir is visible from inside every container, so the condition is satisfied by a sibling's token. That is reachable, not cosmetic. The daemon deliberately exits 0 when it has no token of its own — `Restart = "on-failure"` therefore does not restart it, and the unit sits inactive, which is the state the path unit exists for. In that state a sibling's token keeps the glob satisfied: the path fires, the daemon exits 0, the unit deactivates, the path re-arms, the condition is still true. systemd.path(5) activates a `PathExists`-family condition that already holds immediately on arming, so it repeats until the start limit stops it. Scoped to this agent, the condition is false exactly when the daemon would have nothing to do. The glob is quoted in four other places, all of which would otherwise name a pattern that no longer exists — a doc, a Rust doc-comment in hive-c0re, a nix comment, and an assertion message an operator reads. Each is reworded to the basename (`matrix-token*` in this agent's state dir), which is what the assertion actually enforces via `baseNameOf`, so they stay true wherever the directory moves. Refs #4030.
327 lines
15 KiB
Nix
327 lines
15 KiB
Nix
# Per-agent matrix integration: the `hyperhive.matrix.*` +
|
|
# `hyperhive.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.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
userName = config.hyperhive.user.name;
|
|
# Rasterize the operator-set agent icon (`hyperhive.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 `hyperhive.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.hyperhive.icon} -o $out
|
|
'';
|
|
in
|
|
{
|
|
options.hyperhive.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 `hyperhive.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 (`hyperhive.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 `hyperhive.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.hyperhive.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 (`matrix.<domain>` via the gateway), 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. 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
|
|
misdirected one.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.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 (an
|
|
operator-supplied secret for an external account). The
|
|
daemon skips an extra account whose token file is absent.
|
|
'';
|
|
};
|
|
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 `hyperhive.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 = ''
|
|
Declare *additional* matrix accounts served by the single
|
|
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
|
|
beyond the agent's built-in hive-internal account. The
|
|
attribute name keys 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`, synthesized by the daemon from
|
|
`hyperhive.matrix.url` + `<state>/matrix-token` +
|
|
`<state>/matrix-sdk-state`, and is the account a tool call acts as
|
|
when it omits `account`. You never declare it here --- this option
|
|
is only for the extras (e.g. an external public-matrix account).
|
|
|
|
Leave empty (the default) for the common single-account case: the
|
|
agent then has only `main`. When non-empty, the extras are
|
|
serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment
|
|
variable and the daemon appends them after `main`. Requires
|
|
`hyperhive.matrix.enable` (there is no `main` to extend otherwise).
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.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
|
|
`hyperhive.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 `hyperhive.mcp.httpPort`). Safe as a
|
|
single fixed default across all agents (private per-container
|
|
network namespace — see docs/networking/network.md).
|
|
'';
|
|
};
|
|
|
|
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.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable;
|
|
message =
|
|
"hyperhive.matrixAccounts requires hyperhive.matrix.enable = true "
|
|
+ "(the extras extend the hive-internal `main` account, which only "
|
|
+ "exists when matrix is enabled).";
|
|
}
|
|
# `main` is reserved for the synthesized hive-internal account; a
|
|
# declared extra by that name would silently collide with it.
|
|
{
|
|
assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts;
|
|
message =
|
|
"hyperhive.matrixAccounts cannot contain a key named \"main\" "
|
|
+ "--- that name is reserved for the hive-internal account.";
|
|
}
|
|
# 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 config.hyperhive.matrixAccounts
|
|
);
|
|
message =
|
|
"every hyperhive.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)
|
|
) config.hyperhive.matrixAccounts
|
|
)
|
|
)
|
|
+ ".";
|
|
}
|
|
];
|
|
|
|
# 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.
|
|
hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable {
|
|
matrix = lib.mkDefault {
|
|
type = "http";
|
|
url = "http://127.0.0.1:${toString config.hyperhive.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 config.hyperhive.matrix.enable {
|
|
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 `matrix.<domain>` gateway URL (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.hyperhive.matrix.url != null) {
|
|
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
|
|
}
|
|
# Multi-account: serialize the *extra* accounts to the JSON the
|
|
# daemon parses (`accounts::configured`). Only set when extras are
|
|
# declared; the daemon always synthesizes the primary `main`
|
|
# (hive-internal) account itself from the per-agent paths and
|
|
# prepends it, so we emit extras only. Each entry is in the
|
|
# daemon's `AccountCfg` serde shape: name (the attr key) /
|
|
# token_file / state_dir / optional homeserver.
|
|
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) {
|
|
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; }
|
|
) config.hyperhive.matrixAccounts
|
|
);
|
|
}
|
|
# 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.hyperhive.icon != null) {
|
|
HIVE_ICON_PNG = "${iconPng}";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${config.hyperhive.packages.hive-matrix-daemon}/bin/hive-matrix-daemon --http 127.0.0.1:${toString config.hyperhive.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 config.hyperhive.matrix.enable {
|
|
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 = "/agents/${userName}/state/matrix-token*";
|
|
};
|
|
};
|
|
}
|