diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index e93d36c6..1824171c 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -546,9 +546,15 @@ the body + marker check at that point. process per agent. Serves its MCP tools directly over streamable-http (`services.hyperhive.agent.mcp.matrixHttpPort`, no stdio bridge — same shape as `hive-bash-daemon`), emits hyperhive wake signals -on incoming room events via `/run/hive/mcp.sock`. Conditional on -`services.hyperhive.agent.matrix.enable` (which both the daemon AND the -autoinjected `extraMcpServers.matrix` entry read). +on incoming room events via `/run/hive/mcp.sock`. Conditional on the +agent having at least one `services.hyperhive.agent.matrixAccounts` +entry — there is no separate enable switch, and the same condition gates +the daemon, its path watcher AND the autoinjected +`extraMcpServers.matrix` entry. The module declares the hive-internal +`main` entry whenever `services.hyperhive.agent.matrix.url` is non-null, +so on a real hive (where hive-c0re renders that URL per agent) every +agent has one; a `null` URL with no operator-declared account is the +"this agent has no matrix" state. **First-boot ordering**: hive-c0re provisions the matrix token AFTER agent containers come up. Without the path-trigger sibling diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index b6ea6238..59440b69 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -2,10 +2,14 @@ ## Built-in matrix MCP (`mcp__matrix__*`) -When `services.hyperhive.agent.matrix.enable = true` and the host-level matrix -tuwunel is configured, the harness autoinjects `hive-matrix-daemon`'s +When the agent has at least one `services.hyperhive.agent.matrixAccounts` +entry, the harness autoinjects `hive-matrix-daemon`'s streamable-http endpoint as a second MCP server (no stdio bridge — -see Architecture below). Tools land as `mcp__matrix__`: +see Architecture below). There is no `matrix.enable` switch: the account +set *is* the switch, and the module declares the hive-internal `main` +account for you as soon as `services.hyperhive.agent.matrix.url` is +non-null — which hive-c0re fills in per agent whenever the host-level +matrix tuwunel is configured. Tools land as `mcp__matrix__`: ### Messaging @@ -71,7 +75,8 @@ room you haven't read yet. `services.hyperhive.agent.matrixAccounts` (declared in `agent.nix`) is the agent's full set of matrix identities — for example an -external-facing account alongside the internal one. Each entry is +external-facing account alongside the internal one — **and, being +non-empty, is what turns matrix on for the agent at all.** Each entry is keyed by account name and specifies `tokenFile` (bearer token, provisioned out-of-band; basename must start with `matrix-token`), `sessionDir` (per-account matrix-sdk sqlite state — crypto keys + @@ -83,8 +88,17 @@ declares it for you as an ordinary entry of this map, from here are the *further* accounts. Its `tokenFile` is pinned to `/matrix-token` (hive-c0re provisions it there), and the dashboard's link-account route refuses to create an account by that -name. Declaring extras requires -`services.hyperhive.agent.matrix.enable = true`. +name. + +`main` is present exactly when that URL is non-null, which is the whole +mechanism behind "accounts are the enable signal": an agent with no +homeserver and no account of its own has an empty set, so it gets no +daemon, no path watcher and no injected MCP entry. That is how you give +an agent no matrix tools — it replaces the removed +`services.hyperhive.agent.matrix.enable = false`, which now fails +evaluation with a message saying so. Declaring an external account with +its own `homeserver` is enough on its own; a hive homeserver is not +required. Every matrix tool above takes an optional `account` parameter (a name from this map) to act as that identity instead of the primary one. diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index fea45d18..2ea0a867 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -18,6 +18,25 @@ flakeInputs ? { }, ... }: +let + # What an `agent.nix` still setting the dropped per-agent matrix switch is + # told. Shared by the two `mkRemovedOptionModule` entries below, one per + # spelling of the path. + matrixEnableRemoved = '' + The per-agent matrix switch is gone. Matrix is enabled for an agent exactly + when it has at least one services.hyperhive.agent.matrixAccounts entry, and + the hive-internal `main` entry is declared for you whenever + services.hyperhive.agent.matrix.url is non-null. + + - `enable = true` was the default: drop the line, nothing else to do. + - `enable = false`: set services.hyperhive.agent.matrix.url = null instead + (and declare no accounts of your own). That is what leaves the account set + empty, which is now what suppresses the daemon, its path watcher and the + injected extraMcpServers.matrix entry. + + See docs/tools/matrix.md. + ''; +in { imports = [ ./agent-service.nix @@ -47,6 +66,20 @@ The built-in Bash tool is fully disabled; agents use mcp__bash__run instead. Remove the setting from your agent.nix. '') + # The dropped per-agent matrix switch. A removal, not a silent delete, + # because the definition that *changes meaning* is `false`: left undeclared + # it would be ignored and the agent would quietly gain the matrix tools its + # operator turned off. Failing the eval with the replacement spelling is the + # only outcome that cannot do that. + # + # Both spellings, for the same reason ../host-modules/deploy.nix lists both + # of `swarm|deploy.matrix.registrationTokenFile`: an existing agent.nix may + # carry either the pre-rename `hyperhive.*` path or the current one, and only + # the one it actually carries will fire. The rename shim that used to bridge + # them is gone from ./renamed-options.nix — a rename pointing at an + # undeclared option breaks evaluation for everyone, removed or not. + (lib.mkRemovedOptionModule [ "hyperhive" "matrix" "enable" ] matrixEnableRemoved) + (lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "matrix" "enable" ] matrixEnableRemoved) ]; options.services.hyperhive.agent.icon = lib.mkOption { diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 33f88053..7da62f78 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -3,6 +3,9 @@ # 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, @@ -16,6 +19,21 @@ let # 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). @@ -27,40 +45,6 @@ let ''; in { - options.services.hyperhive.agent.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 `services.hyperhive.agent.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 - `/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 (`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] in : …`) - 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 `services.hyperhive.agent.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.services.hyperhive.agent.matrix.url = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -80,10 +64,18 @@ in 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 + 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. ''; }; @@ -143,12 +135,41 @@ in 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`, and this module declares it for you (when - `services.hyperhive.agent.matrix.enable` is set) from + **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] in : …`) + 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` + `/matrix-token` + - `/matrix-sdk-state`. It is the account a tool call acts as - when it omits `account`. It is an ordinary entry of this option + `/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 @@ -160,8 +181,7 @@ in 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. Declaring extras requires - `services.hyperhive.agent.matrix.enable` (there is no `main` to extend otherwise). + first. ''; }; @@ -185,29 +205,26 @@ in 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.services.hyperhive.agent.matrixAccounts == { } - || config.services.hyperhive.agent.matrix.enable; - message = - "services.hyperhive.agent.matrixAccounts requires services.hyperhive.agent.matrix.enable = true " - + "(the extras extend the hive-internal `main` account, which only " - + "exists when matrix is enabled)."; - } - # `main` is no longer a forbidden key --- this module declares it + # 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 `/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 = - !config.services.hyperhive.agent.matrix.enable - || config.services.hyperhive.agent.matrixAccounts.main.tokenFile == "${stateDir}/matrix-token"; + 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 " @@ -222,7 +239,7 @@ in # `matrix-catgirl-token`) is caught at build time, not silently. { assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) ( - lib.attrValues config.services.hyperhive.agent.matrixAccounts + lib.attrValues accounts ); message = "every services.hyperhive.agent.matrixAccounts..tokenFile basename must start with " @@ -230,9 +247,7 @@ in + "(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.services.hyperhive.agent.matrixAccounts + lib.filterAttrs (_n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) accounts ) ) + "."; @@ -246,19 +261,29 @@ in # 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). - services.hyperhive.agent.matrixAccounts = lib.mkIf config.services.hyperhive.agent.matrix.enable { - main = { - tokenFile = lib.mkDefault "${stateDir}/matrix-token"; - sessionDir = lib.mkDefault "${stateDir}/matrix-sdk-state"; - homeserver = lib.mkDefault config.services.hyperhive.agent.matrix.url; - }; - }; + # + # ⚠️ 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 config.services.hyperhive.agent.matrix.enable { + 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"; @@ -271,7 +296,7 @@ in # 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.services.hyperhive.agent.matrix.enable { + systemd.services.hive-matrix-daemon = lib.mkIf matrixEnabled { description = "long-running matrix-sdk Client + MCP daemon"; wantedBy = [ "multi-user.target" ]; before = [ "hive-agent.service" ]; @@ -304,7 +329,10 @@ in # 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. - // lib.optionalAttrs (config.services.hyperhive.agent.matrixAccounts != { }) { + # + # 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: @@ -314,7 +342,7 @@ in state_dir = a.sessionDir; } // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } - ) config.services.hyperhive.agent.matrixAccounts + ) accounts ); } # Rasterized agent icon path for the daemon's avatar sync. Only set @@ -347,7 +375,7 @@ in # 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.services.hyperhive.agent.matrix.enable { + 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 diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 0144ff8f..094ec4ec 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -273,7 +273,8 @@ in # Auto-inject the built-in bash MCP server — always present, every # agent needs bash tools. `lib.mkDefault` so the operator's own # agent.nix can override the entry. (The matrix sibling lives in - # ./matrix.nix, gated on services.hyperhive.agent.matrix.enable.) `hive-bash-daemon` + # ./matrix.nix, gated on that agent having a + # services.hyperhive.agent.matrixAccounts entry at all.) `hive-bash-daemon` # serves its MCP tools directly over streamable-http (no stdio bridge, # no round-trip socket) — see the `hive-bash-daemon` service below. services.hyperhive.agent.extraMcpServers.bash = lib.mkDefault { @@ -284,8 +285,9 @@ in # Auto-inject the subagent MCP server — default-on for every agent for # now (operator's call: "default on for now, should be a capability - # later" — not gated behind an enable option yet, unlike `matrix.nix`'s - # pattern). `lib.mkDefault` so an agent.nix can still override/disable + # later" — unconditional, where `matrix.nix`'s sibling entry appears only + # for an agent that has a matrix account to serve). + # `lib.mkDefault` so an agent.nix can still override/disable # the entry in the meantime. services.hyperhive.agent.extraMcpServers.subagent = lib.mkDefault { type = "http"; diff --git a/nix/agent-modules/renamed-options.nix b/nix/agent-modules/renamed-options.nix index cbf6d554..ef6e025b 100644 --- a/nix/agent-modules/renamed-options.nix +++ b/nix/agent-modules/renamed-options.nix @@ -119,10 +119,10 @@ [ "hyperhive" "logs" "queryUrl" ] [ "services" "hyperhive" "agent" "logs" "queryUrl" ] ) - (lib.mkRenamedOptionModule - [ "hyperhive" "matrix" "enable" ] - [ "services" "hyperhive" "agent" "matrix" "enable" ] - ) + # `matrix.enable` is deliberately absent from this list: the option it + # renamed to no longer exists, and a rename pointing at an undeclared + # option breaks evaluation for everyone. Both of its spellings are + # `mkRemovedOptionModule` in ./default.nix instead. (lib.mkRenamedOptionModule [ "hyperhive" "matrix" "url" ] [ "services" "hyperhive" "agent" "matrix" "url" ] diff --git a/nix/module-eval.nix b/nix/module-eval.nix index b0b26984..cb28baec 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -611,6 +611,29 @@ let # RAM percentage and so hands the module no byte count to size against. agentCapped = agent { claudeMemoryMaxBytes = 8589934592; }; agentUncapped = agent { }; + + # Matrix's enable signal, which is the account set itself — there is no + # `matrix.enable` option left to read. Three arms, because the property has + # three distinct shapes and only one of them is the common case: + # + # - a homeserver URL, which is what the module turns into a `main` account; + # - neither URL nor operator account, the state that replaced + # `matrix.enable = false`. ⚠️ **This is the arm that matters.** `main` is + # declared by the module itself, so "any account declared" would be + # trivially true — and matrix would render for every agent in every hive — + # the moment that declaration stops being gated on the URL. Nothing else in + # this suite would notice; + # - an operator account carrying its own homeserver and no hive one, which is + # matrix on with no `main` at all. + agentMatrix = agent { matrix.url = "https://chat.t.local"; }; + agentNoMatrix = agent { }; + agentMatrixExternalOnly = agent { + matrixAccounts.ccc = { + tokenFile = "/agents/a1/state/matrix-token-ccc"; + sessionDir = "/agents/a1/state/matrix-sdk-state-ccc"; + homeserver = "https://matrix.example.invalid"; + }; + }; agentHarness = machine: machine.systemd.services.hive-agent; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; agentSettings = machine: machine.services.opentelemetry-collector.settings; @@ -1793,6 +1816,62 @@ let name = "one subagent losing the OOM draw does not stop the daemon"; ok = (agentSubagentDaemon agentUncapped).serviceConfig.OOMPolicy == "continue"; } + { + # A homeserver URL is the whole input: from it the module derives the + # hive-internal `main` account, and from a non-empty account set the three + # things that used to hang off `matrix.enable`. + name = "an agent with a homeserver gets a main account and the matrix units"; + ok = + let + a = agentMatrix.services.hyperhive.agent.matrixAccounts; + in + lib.attrNames a == [ "main" ] + && a.main.tokenFile == "/agents/a1/state/matrix-token" + && a.main.homeserver == "https://chat.t.local" + && agentMatrix.systemd.services ? hive-matrix-daemon + && agentMatrix.systemd.paths ? hive-matrix-daemon + && agentMatrix.services.hyperhive.agent.extraMcpServers ? matrix; + } + { + # The absence arm, and the reason the enable signal is not vacuous. An + # agent the hive gave no homeserver, whose operator declared nothing, must + # come out with an EMPTY account set — not a `main` that can never log in + # — and therefore with none of the three. Assert the emptiness itself and + # not just the units: it is the account set that is load-bearing now, and + # a `main` sneaking back in is the regression this case exists to name. + name = "an agent with no homeserver and no declared account gets no matrix at all"; + ok = + agentNoMatrix.services.hyperhive.agent.matrixAccounts == { } + && !(agentNoMatrix.systemd.services ? hive-matrix-daemon) + && !(agentNoMatrix.systemd.paths ? hive-matrix-daemon) + && !(agentNoMatrix.services.hyperhive.agent.extraMcpServers ? matrix); + } + { + # Matrix without a hive homeserver: one operator account, its own + # homeserver, no `main`. Under the deleted `matrix.enable` this config was + # an assertion failure ("extras require enable") even though every account + # in it was complete; the account set being the signal is what makes it + # expressible, and the serialized env var is where that has to show up. + name = "an external-only account enables matrix with no main entry"; + ok = + let + accts = agentMatrixExternalOnly.services.hyperhive.agent.matrixAccounts; + env = agentMatrixExternalOnly.systemd.services.hive-matrix-daemon.environment; + in + lib.attrNames accts == [ "ccc" ] + && !(accts ? main) + && + builtins.fromJSON env.HIVE_MATRIX_ACCOUNTS == [ + { + name = "ccc"; + token_file = "/agents/a1/state/matrix-token-ccc"; + state_dir = "/agents/a1/state/matrix-sdk-state-ccc"; + homeserver = "https://matrix.example.invalid"; + } + ] + # No hive homeserver, so nothing may claim one. + && !(env ? HIVE_MATRIX_URL); + } { # The doctrine three glue files state, as a property a rewrite has to # keep: a client is defined by holding a certificate the store accepts,