Third and last of #2860's agent-facing URL fallbacks. The operator's ruling was "any special casing is done on the nix side - same binaries, no hard coded fallback", so the default is deleted rather than replaced. Every layer guessed the same wrong thing, and each guess was only ever correct for a process sharing the host netns: - nix/agent-modules/matrix.nix: matrixUrlDefault = localhost:8008, both as the option's default and as a sentinel the daemon unit compared against to decide whether to write HIVE_MATRIX_URL. Now nullOr str, default null, the guard is != null, and the doc says what forge.url's already says: null means "no matrix", not "guess one". - nix/host-modules/hive-c0re/environment.nix: forwarded http://127.0.0.1:<port> when no gatewayHost was set. hive-c0re shares the host netns so it reads as harmless, but the value is handed to agents, which do not -- there it names the agent itself. Now forwarded only when there is a gateway vhost to name, matching the guard HIVE_MATRIX_PUBLIC_URL already uses twelve lines below. - hive-matrix-mcp: paths::DEFAULT_HOMESERVER was the same address compiled in, so dropping the nix defaults alone would have left the daemon dialling loopback inside the agent's own netns -- the very bug, one layer down. homeserver_url() is now Option, and an account with no homeserver is skipped with a log, exactly as one with no token is. discover_token_accounts already refused to guess for the same reason. Two comments taught the assumption back to the next reader ("shared host netns means every agent container resolves localhost to the same machine"); both now say which side of the netns boundary they describe. MATRIX_HTTP keeps its value -- hive-c0re really does share the host netns -- but no longer claims agents do. Gated with nix eval against the extended agent-base config, as a pair: with no url set the daemon unit carries no HIVE_MATRIX_URL, and with one set it carries exactly that. Either check alone passes on a broken guard.
317 lines
14 KiB
Nix
317 lines
14 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/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 (`/agents/*/state/matrix-token*`), 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 "
|
|
+ "(/agents/*/state/matrix-token*) 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/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/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.
|
|
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
|
|
};
|
|
};
|
|
}
|