diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index 0cfba598..044bed95 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 = "https://matrix.example"; # default: null +hyperhive.matrix.url = "http://localhost:8008"; # default ``` **`hyperhive.forge.url`** — base URL of the Forgejo instance. Used by @@ -155,22 +155,12 @@ 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. 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. +`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. ## Claude Code plugins diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index d724194c..eff37f84 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -14,39 +14,14 @@ use reqwest::StatusCode; use crate::coordinator::Coordinator; -/// Client-server API base this daemon provisions against, from -/// `HIVE_MATRIX_API_URL` (set by `hive-c0re.nix` from -/// `hyperhive.matrix.apiUrl`). -/// -/// `None` means **this hive has no homeserver to provision against** and -/// every matrix path no-ops — see [`is_present`]. There is deliberately no -/// fallback: `localhost:8008` is right only when the homeserver happens to -/// share this daemon's netns, and an address baked into the binary is one -/// that builds fine and then talks to the wrong machine. The nix module -/// supplies the loopback address when it is itself the thing running -/// tuwunel, where it is not a guess but a fact about what it just started. -/// -/// Also not an agent-facing address either way. An agent has its own netns; -/// agents are handed the gateway vhost via `HIVE_MATRIX_URL`, and get -/// nothing at all when the hive has no vhost to offer. -fn matrix_http() -> Option<&'static str> { - static BASE: std::sync::OnceLock> = std::sync::OnceLock::new(); - BASE.get_or_init(|| std::env::var("HIVE_MATRIX_API_URL").ok()) - .as_deref() -} - -/// [`matrix_http`] for the call sites that propagate with `?`. -/// -/// # Errors -/// When no homeserver is configured. Reaching one of these paths at all -/// means an [`is_present`] gate was skipped, so the message names that -/// rather than the missing variable. -fn matrix_base() -> Result<&'static str> { - matrix_http().context( - "matrix: no homeserver configured (hyperhive.matrix.apiUrl / HIVE_MATRIX_API_URL) — \ - this path should have been gated on matrix::is_present()", - ) -} +/// nspawn container name for the matrix homeserver — mirrors +/// `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. +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. const REGISTER_TOKEN_BYTES: usize = 32; @@ -132,17 +107,15 @@ pub fn hive_chat_room_id_path() -> PathBuf { crate::paths::matrix_chat_room_id() } -/// Whether this hive has a homeserver to provision against. -/// -/// **A configured API URL, not a local container.** It used to scan -/// `nixos-container list` for `hive-matrix`, which answers a different -/// question — "is the homeserver a container on this host" — and so made a -/// remote homeserver silently no-op no matter how it was addressed. The -/// nix module still supplies the loopback URL whenever it runs tuwunel -/// itself, so a co-located hive behaves exactly as before. -#[must_use] -pub fn is_present() -> bool { - matrix_http().is_some() +/// Probe whether `hive-matrix` exists as a nixos-container. Cheap — +/// `nixos-container list` is just a directory scan in /etc. Same shape +/// as `forge::is_present` — routed through hive-priv since +/// `nixos-container` needs root and hive-c0re runs unprivileged. +pub async fn is_present() -> bool { + let Ok(stdout) = crate::priv_client::list_containers().await else { + return false; + }; + stdout.lines().any(|l| l.trim() == MATRIX_CONTAINER) } /// Read `n` cryptographic-quality bytes from `/dev/urandom` and return @@ -205,8 +178,7 @@ async fn register_post( client: &reqwest::Client, body: &serde_json::Value, ) -> Result<(StatusCode, serde_json::Value)> { - let base = matrix_base()?; - let url = format!("{base}/_matrix/client/v3/register"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/register"); let resp = client .post(&url) .json(body) @@ -303,8 +275,7 @@ fn extract_access_token(body: &serde_json::Value) -> Result { /// in which case manual recovery via `hivectl matrix create-user` is /// required. async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result { - let base = matrix_base()?; - let url = format!("{base}/_matrix/client/v3/login"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/login"); let body = serde_json::json!({ "type": "m.login.password", "identifier": { @@ -369,10 +340,9 @@ async fn discover_admin_room_id( admin_token: &str, server_name: &str, ) -> Result { - let base = matrix_base()?; // #admins:server → %23admins%3A let encoded_alias = format!("%23admins%3A{server_name}"); - let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded_alias}"); let resp = client .get(&url) .bearer_auth(admin_token) @@ -509,11 +479,10 @@ async fn admin_room_send_and_poll( command: &str, check: impl Fn(&str) -> Option, ) -> Result { - let base = matrix_base()?; // Send the command; record the event_id so we can use it as an anchor. let txn_id = random_hex(8)?; let send_url = - format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); let send_resp = client .put(&send_url) .bearer_auth(admin_token) @@ -538,7 +507,8 @@ async fn admin_room_send_and_poll( // on each tick. Walk the list until we hit our own command event_id; // everything *before* that marker arrived after our command. let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); - let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); + let poll_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client @@ -793,7 +763,7 @@ pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &s /// setup in [`ensure_all`] so the rebuild path and the startup sweep /// stay equivalent. No-op when the matrix container is absent. pub async fn sync_agent_standalone(name: &str) { - if !is_present() { + if !is_present().await { return; } let register_token = match ensure_register_token() { @@ -963,8 +933,7 @@ fn persist_password(localpart: &str, password: &str) { /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// The response JSON always includes `"server_name"` per the matrix spec. pub async fn discover_server_name(client: &reqwest::Client) -> Result { - let base = matrix_base()?; - let url = format!("{base}/_matrix/key/v2/server"); + let url = format!("{MATRIX_HTTP}/_matrix/key/v2/server"); let resp = client .get(&url) .send() @@ -1026,8 +995,7 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option { - let base = matrix_http()?; - let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); + let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) @@ -1044,7 +1012,8 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti }; let encoded = encode_room_id_for_url(room_id); // Must be an m.space (m.room.create `type`). - let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); + let create_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let is_space = match client .get(&create_url) .bearer_auth(admin_token) @@ -1062,7 +1031,8 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti continue; } // …and named HIVE_SPACE_NAME (m.room.name `name`). - let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); + let name_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() @@ -1095,7 +1065,6 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { - let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { let trimmed = existing.trim().to_owned(); @@ -1114,7 +1083,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R } // 3. Create the space (plain hardcoded name, no alias). - let url = format!("{base}/_matrix/client/v3/createRoom"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, @@ -1164,8 +1133,7 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option { - let base = matrix_http()?; - let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); + let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) @@ -1182,7 +1150,8 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> }; let encoded = encode_room_id_for_url(room_id); // Skip the Space itself (and any other m.space). - let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); + let create_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let is_space = match client .get(&create_url) .bearer_auth(admin_token) @@ -1200,7 +1169,8 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> continue; } // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). - let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); + let name_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() @@ -1226,11 +1196,11 @@ async fn set_room_state( state_key: &str, content: &serde_json::Value, ) -> Result<()> { - let base = matrix_base()?; let encoded_room = encode_room_id_for_url(room_id); let encoded_key = encode_room_id_for_url(state_key); - let url = - format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); + let url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}" + ); let resp = client .put(&url) .bearer_auth(admin_token) @@ -1273,7 +1243,6 @@ pub async fn ensure_hive_chat_room( space_room_id: &str, server_name: &str, ) -> Result { - let base = matrix_base()?; // 1. Stored room id wins (fast path). 2. Rediscover by name before // creating (prevents duplicates after a state wipe). 3. Create. let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path()) @@ -1290,7 +1259,7 @@ pub async fn ensure_hive_chat_room( } else { // `initial_state` is applied after the preset-derived state, so the // restricted join rule overrides private_chat's invite-only default. - let url = format!("{base}/_matrix/client/v3/createRoom"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_CHAT_ROOM_NAME, "topic": HIVE_CHAT_ROOM_TOPIC, @@ -1390,12 +1359,11 @@ async fn room_membership( encoded_room_id: &str, user_id: &str, ) -> Option { - let base = matrix_http()?; // `:` must be percent-encoded in both the room-id and user-id path // segments; `@` and `!` are permitted path characters per RFC 3986. let encoded_user = user_id.replace(':', "%3A"); let url = format!( - "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" ); let resp = client .get(&url) @@ -1423,7 +1391,6 @@ async fn invite_user_id( room_id: &str, user_id: &str, ) -> Result<()> { - let base = matrix_base()?; // `:` must be percent-encoded in the room-id path segment; `!` is // permitted in URL path characters per RFC 3986. let encoded_room_id = room_id.replace(':', "%3A"); @@ -1438,7 +1405,7 @@ async fn invite_user_id( return Ok(()); } - let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) .bearer_auth(admin_token) @@ -1518,9 +1485,8 @@ async fn resolve_room_alias( admin_token: &str, alias: &str, ) -> Result { - let base = matrix_base()?; let encoded = alias.replace('#', "%23").replace(':', "%3A"); - let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) .bearer_auth(admin_token) @@ -1551,7 +1517,7 @@ async fn resolve_room_alias( /// dashboard banner on persistent failure (this sweep re-runs every 30 /// minutes, so a one-off blip self-heals without ever bannering). pub async fn ensure_all() -> bool { - if !is_present() { + if !is_present().await { tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); return true; } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 26803aa2..b9caf19f 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -463,19 +463,13 @@ async fn handle_set_resource_limits( )])) } -/// Guard: matrix provisioning needs a homeserver to provision against. -/// -/// Answers "is one configured", not "is one running here" — the message names -/// both ways to get there, since a hive that talks to someone else's -/// homeserver never enables the local container at all. -fn require_matrix_present() -> Result<()> { - if crate::matrix::is_present() { +/// Guard: matrix provisioning needs the homeserver container running. +async fn require_matrix_present() -> Result<()> { + if crate::matrix::is_present().await { return Ok(()); } anyhow::bail!( - "no matrix homeserver configured — set services.hyperhive.matrix.enable = true to run one \ - here, or services.hyperhive.matrix.apiUrl to point at an existing one, before \ - provisioning matrix users" + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" ) } @@ -483,7 +477,7 @@ async fn handle_matrix_create_user( name: &hive_types::Ident, password: Option<&str>, ) -> Result { - require_matrix_present()?; + require_matrix_present().await?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; let client = matrix_http_client()?; @@ -695,7 +689,7 @@ async fn handle_push_snapshot( } async fn handle_matrix_sync_admin() -> Result { - require_matrix_present()?; + require_matrix_present().await?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; let client = matrix_http_client()?; @@ -713,7 +707,7 @@ async fn handle_matrix_sync_admin() -> Result { } async fn handle_matrix_promote_user(name: &str) -> Result { - require_matrix_present()?; + require_matrix_present().await?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) @@ -728,7 +722,7 @@ async fn handle_matrix_promote_user(name: &str) -> Result { } async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { - require_matrix_present()?; + require_matrix_present().await?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) @@ -748,7 +742,7 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { - require_matrix_present()?; + require_matrix_present().await?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index d7cdb9ec..8e2ec5fd 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -46,15 +46,13 @@ pub struct AccountCfg { } impl AccountCfg { - /// 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. + /// Resolve the effective homeserver URL (per-account override or + /// the daemon-wide default). #[must_use] - pub fn homeserver(&self) -> Option { - self.homeserver.clone().or_else(paths::homeserver_url) + pub fn homeserver(&self) -> String { + self.homeserver + .clone() + .unwrap_or_else(paths::homeserver_url) } } diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 3bbb77ab..73b76995 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -265,17 +265,7 @@ async fn bring_up_account( tag: Option, is_primary: bool, ) -> Result> { - 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); - }; + let homeserver = cfg.homeserver(); 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 bb6e8f59..cac9bbf1 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -6,6 +6,12 @@ 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 @@ -19,19 +25,11 @@ pub fn token_file() -> PathBuf { PathBuf::from(format!("{state_dir}/matrix-token")) } -/// 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. +/// Resolve the homeserver URL. Override via `HIVE_MATRIX_URL`; default +/// is the in-container `localhost:8008` tuwunel. #[must_use] -pub fn homeserver_url() -> Option { - std::env::var("HIVE_MATRIX_URL").ok() +pub fn homeserver_url() -> String { + std::env::var("HIVE_MATRIX_URL").unwrap_or_else(|_| DEFAULT_HOMESERVER.to_owned()) } /// 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 23f5d3d2..efcf39fd 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -11,6 +11,12 @@ }: 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). @@ -30,13 +36,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 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`). + 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`). - 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 @@ -57,27 +63,17 @@ in }; options.hyperhive.matrix.url = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; + type = lib.types.str; + default = matrixUrlDefault; 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.` 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. + 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). ''; }; @@ -244,15 +240,15 @@ in 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.` 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) { + # 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) { 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 956a23af..3021eb18 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -100,38 +100,18 @@ 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 && 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.apiUrl != null) { - # Client-server API base hive-c0re uses to provision matrix (register - # agent users, create the hive space + chat room, invite members). - # Supplied by `services.hyperhive.matrix.apiUrl`, which the matrix - # module fills in with its own loopback listener when it is the thing - # running tuwunel — and which the operator sets by hand when the - # homeserver lives on another machine. - # - # NOT the agent-facing HIVE_MATRIX_URL above: that one is the gateway - # vhost, and it is absent whenever there is no vhost. Reusing it here - # would silently stop provisioning on a hive that runs matrix without - # one. - HIVE_MATRIX_API_URL = config.services.hyperhive.matrix.apiUrl; +// 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.gui.enable { # Availability flags read by the dashboard's `/api/state`. diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 0f973ea9..98fed0db 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -147,37 +147,6 @@ in ''; }; - apiUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = if cfg.enable then "http://127.0.0.1:${toString cfg.httpPort}" else null; - defaultText = lib.literalExpression '' - if services.hyperhive.matrix.enable - then "http://127.0.0.1:''${toString services.hyperhive.matrix.httpPort}" - else null - ''; - example = "https://matrix.example.com"; - description = '' - Client-server API base URL **hive-c0re itself** uses to - provision matrix (register agent users, create the hive space - and chat room, invite members). Distinct from the agent-facing - `hyperhive.matrix.url`, which is the gateway vhost handed to - each agent's `hive-matrix-daemon`. - - Defaults to the loopback listener **only when this module is the - thing running tuwunel** — in that case the address is not a - guess, it is where this module just put the container. Set it - explicitly (with `enable = false`) when the homeserver runs on - another machine; "everything on one host" is a special case of - the full deployment, not the assumption. - - `null` means hive-c0re has no homeserver to provision against - and matrix provisioning no-ops. There is deliberately no - fallback compiled into the daemon: an address baked into the - binary is one that builds fine and then talks to the wrong - machine. - ''; - }; - gatewayHost = lib.mkOption { type = lib.types.nullOr lib.types.str; default = "matrix.${hyperhiveDomain}";