From 0e9b1c563d7a5e23bc33ed0bab06bdf7e84e92cd Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 2 Aug 2026 14:45:15 +0200 Subject: [PATCH] fix(#2860): no loopback default for the matrix homeserver 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: 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. --- docs/turn-loop/config.md | 24 +++++--- hive-c0re/src/matrix.rs | 11 +++- hive-matrix-mcp/src/accounts.rs | 14 +++-- hive-matrix-mcp/src/main.rs | 12 +++- hive-matrix-mcp/src/paths.rs | 22 ++++---- nix/agent-modules/matrix.nix | 64 ++++++++++++---------- nix/host-modules/hive-c0re/environment.nix | 32 ++++++----- 7 files changed, 109 insertions(+), 70 deletions(-) diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index 044bed95..0cfba598 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -132,7 +132,7 @@ setups. ```nix hyperhive.forge.url = "http://forge.example:3000"; # default: null -hyperhive.matrix.url = "http://localhost:8008"; # default +hyperhive.matrix.url = "https://matrix.example"; # default: null ``` **`hyperhive.forge.url`** — base URL of the Forgejo instance. Used by @@ -155,12 +155,22 @@ flake without one, so `null` only survives where the agent modules are evaluated outside a hive. **`hyperhive.matrix.url`** — homeserver URL used by -`hive-matrix-daemon` when connecting via the matrix-sdk. Default -(`localhost:8008`) is overridden by hive-c0re at deploy time to the -gateway-routed `matrix.` URL so isolated agents can reach the -homeserver. Override per-agent when an agent should talk to a -different homeserver — for example a remote hive's tuwunel reached -over a VPN, or an external Matrix server for a federation-only agent. +`hive-matrix-daemon` when connecting via the matrix-sdk. hive-c0re +writes it into every agent at deploy time as the gateway-routed +`matrix.` URL, so isolated agents can reach the homeserver. +Override per-agent when an agent should talk to a different homeserver +— for example a remote hive's tuwunel reached over a VPN, or an +external Matrix server for a federation-only agent. + +**Defaults to `null`, meaning "no matrix" — for the same reason +`forge.url` does.** The homeserver may live on another host, and a +loopback default resolves inside the agent's own netns to the agent, +so it would be a value that evaluates fine and then talks to the wrong +machine. With `null` the daemon has no homeserver and no-ops exactly as +it does without a token. The hive only forwards `HIVE_MATRIX_URL` when +it actually has a matrix vhost to name, so `null` survives where a hive +runs no homeserver, or where the agent modules are evaluated outside a +hive. ## Claude Code plugins diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index eff37f84..4f98ebc9 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -18,9 +18,14 @@ use crate::coordinator::Coordinator; /// `hive-forge` and matches the bare-name allow-list the lifecycle /// scanner skips over. const MATRIX_CONTAINER: &str = "hive-matrix"; -/// Local-host URL of the tuwunel client-server API. Shares the host -/// netns so `localhost:` resolves both from the daemon and from -/// inside any sub-agent container. +/// Local-host URL of the tuwunel client-server API, for **this daemon +/// only**: hive-c0re and the homeserver share the host netns, so +/// `localhost:` reaches it from here. +/// +/// Deliberately not an agent-facing address. An agent has its own netns, +/// where `localhost` is the agent — agents are handed the gateway vhost +/// (`matrix.`) via `HIVE_MATRIX_URL` instead, and get nothing at +/// all when the hive has no vhost to offer. const MATRIX_HTTP: &str = "http://localhost:8008"; /// Length (bytes) of the random registration token. 32 raw bytes ⇒ /// 64-char hex string; comfortable for a long-lived shared secret. diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index 8e2ec5fd..d7cdb9ec 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -46,13 +46,15 @@ pub struct AccountCfg { } impl AccountCfg { - /// Resolve the effective homeserver URL (per-account override or - /// the daemon-wide default). + /// The effective homeserver URL — this account's own, else the + /// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set. + /// + /// `None` is a real answer, not a failure: the account is skipped, the + /// same way [`discover_token_accounts_in`] already skips a discovered + /// token whose homeserver sidecar is missing. #[must_use] - pub fn homeserver(&self) -> String { - self.homeserver - .clone() - .unwrap_or_else(paths::homeserver_url) + pub fn homeserver(&self) -> Option { + self.homeserver.clone().or_else(paths::homeserver_url) } } diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 73b76995..3bbb77ab 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -265,7 +265,17 @@ async fn bring_up_account( tag: Option, is_primary: bool, ) -> Result> { - let homeserver = cfg.homeserver(); + let Some(homeserver) = cfg.homeserver() else { + // No homeserver for this account: the hive has none to offer (no + // matrix vhost) or this agent's `hyperhive.matrix.url` is null. Same + // no-op as a missing token — an absent integration, not a guess at + // one. + tracing::info!( + account = %cfg.name, + "no homeserver configured (HIVE_MATRIX_URL unset); skipping account" + ); + return Ok(None); + }; if !tokio::fs::try_exists(&cfg.token_file) .await .unwrap_or(false) diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index cac9bbf1..bb6e8f59 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -6,12 +6,6 @@ use std::path::PathBuf; -/// Default homeserver URL when `HIVE_MATRIX_URL` isn't set. Tuwunel -/// (the local hive-matrix container) listens on `localhost:8008` by -/// default; shared host netns means every agent container resolves -/// `localhost` to the same machine. -pub const DEFAULT_HOMESERVER: &str = "http://localhost:8008"; - /// Resolve the matrix access-token file path. Override via /// `HIVE_MATRIX_TOKEN_FILE`; default is `/matrix-token`, /// the path `hive-c0re::matrix::ensure_user_for` writes to on agent @@ -25,11 +19,19 @@ pub fn token_file() -> PathBuf { PathBuf::from(format!("{state_dir}/matrix-token")) } -/// Resolve the homeserver URL. Override via `HIVE_MATRIX_URL`; default -/// is the in-container `localhost:8008` tuwunel. +/// Resolve the homeserver URL from `HIVE_MATRIX_URL`, or `None` when the +/// harness didn't set one. +/// +/// There is deliberately no built-in default. A hardcoded `localhost:8008` +/// used to stand in here, on the reasoning that the tuwunel container shares +/// the host netns — but *this daemon runs inside an agent*, which does not, so +/// that address named the agent itself. Unset means the hive has no homeserver +/// to offer this agent, and the daemon no-ops exactly as it does without a +/// token; guessing would be a value that starts fine and then talks to the +/// wrong machine. #[must_use] -pub fn homeserver_url() -> String { - std::env::var("HIVE_MATRIX_URL").unwrap_or_else(|_| DEFAULT_HOMESERVER.to_owned()) +pub fn homeserver_url() -> Option { + std::env::var("HIVE_MATRIX_URL").ok() } /// Persistent sqlite store directory for matrix-sdk's state (event diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index efcf39fd..23f5d3d2 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -11,12 +11,6 @@ }: let userName = config.hyperhive.user.name; - # Single source of truth for the default matrix homeserver URL, shared - # by the `hyperhive.matrix.url` option default and the daemon-unit guard - # that decides whether to set a unit-level HIVE_MATRIX_URL (so the two - # cannot drift). Matches the daemon's own built-in default - # (`paths::DEFAULT_HOMESERVER`). - matrixUrlDefault = "http://localhost:8008"; # 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). @@ -36,13 +30,13 @@ in When true (the default), the harness: - runs `hive-matrix-daemon` as a systemd unit that holds a - matrix-sdk Client + sync against the homeserver at - `HIVE_MATRIX_URL` (default `http://localhost:8008` — the - in-host tuwunel from `nix/host-modules/hive-matrix.nix`). The - daemon auto-skips when `/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`). + 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 + `/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 @@ -63,17 +57,27 @@ in }; options.hyperhive.matrix.url = lib.mkOption { - type = lib.types.str; - default = matrixUrlDefault; + 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. At runtime hive-c0re forwards the isolation-aware URL - (`matrix.` via the gateway) so isolated agents reach - the homeserver without crossing host loopback. Override - per-agent when an agent should talk to an external homeserver - instead (e.g. a federation-only setup or a remote hive's - tuwunel reached via a vpn). + to. hive-c0re writes this per agent from the hive's own + isolation-aware URL (`matrix.` 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. ''; }; @@ -240,15 +244,15 @@ in HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; RUST_LOG = "info"; } - # Homeserver URL: by default the daemon inherits the host-forwarded - # HIVE_MATRIX_URL (set by hive-c0re to `matrix.` via the - # gateway, since agents run in private netns and can't reach host - # loopback directly), falling back to the daemon's built-in - # localhost default if the forward is absent. A per-agent - # `hyperhive.matrix.url` override (non-default) is set unit-level - # so it wins over the forwarded value; at the default we - # deliberately DON'T set it so the forwarded value isn't shadowed. - // lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) { + # Homeserver URL. hive-c0re writes this option per agent from the + # hive's own `matrix.` 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 diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 3021eb18..f5fe30f4 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -100,19 +100,25 @@ in # gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`. HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}"; } -// lib.optionalAttrs config.services.hyperhive.matrix.enable { - # In-cluster matrix homeserver URL for each agent's - # hive-matrix-daemon — the gateway vhost (`matrix.`). The - # gatewayHost null-guard falls back to loopback so a domain-less - # config still evals. Forwarded to agents by meta.rs alongside - # HIVE_FORGE_URL; shares the same env-forwarding ordering caveat - # (value baked at config-generation time). - HIVE_MATRIX_URL = - if config.services.hyperhive.matrix.gatewayHost != null then - "http://${config.services.hyperhive.matrix.gatewayHost}" - else - "http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}"; -} +// + lib.optionalAttrs + (config.services.hyperhive.matrix.enable && config.services.hyperhive.matrix.gatewayHost != null) + { + # In-cluster matrix homeserver URL for each agent's + # hive-matrix-daemon — the gateway vhost (`matrix.`). + # Forwarded to agents by meta.rs alongside HIVE_FORGE_URL; shares the + # same env-forwarding ordering caveat (value baked at + # config-generation time). + # + # A domain-less config forwards nothing rather than falling back to + # loopback. The old fallback read as harmless because hive-c0re shares + # the host netns — but the value it produced was handed to *agents*, + # which do not, so `127.0.0.1` there names the agent itself. An absent + # forward leaves `hyperhive.matrix.url` null and the daemon no-ops; + # that is the honest answer when the hive has no matrix vhost to point + # at. + HIVE_MATRIX_URL = "http://${config.services.hyperhive.matrix.gatewayHost}"; + } // lib.optionalAttrs config.services.hyperhive.matrix.gui.enable { # Availability flags read by the dashboard's `/api/state`. # Matrix GUI lives entirely on the gateway nginx (matrix tab