Compare commits

...
Author SHA1 Message Date
atlas
d3f2d246e3 fix(#2860): hive-c0re stops assuming matrix is on localhost
`MATRIX_HTTP` was `http://localhost:8008`, compiled in, used at 18 call
sites. That address is right only while the homeserver happens to share
this daemon's netns, and its doc comment asserted exactly that as a
general fact. A hive whose homeserver lives anywhere else builds fine
and then talks to the wrong machine.

It now reads `HIVE_MATRIX_API_URL`, which `hive-c0re.nix` sets from
`hyperhive.matrix.apiUrl`. The matrix module fills that in with its own
loopback listener when it is the thing running tuwunel — there it is not
a guess but a fact about what it just started — and the operator sets it
by hand otherwise. There is no compiled-in fallback, for the same reason
`forge_http_base()` has none.

`is_present()` follows. It used to scan `nixos-container list` for
`hive-matrix`, which answers "is the homeserver a container on this
host" — a different question, and the reason a remote homeserver would
silently no-op no matter how it was addressed. It now asks whether a URL
is configured. A co-located hive is unaffected: the module supplies the
loopback URL whenever it runs tuwunel itself. It also stops being
`async`, since it no longer does IO, and `require_matrix_present`'s
message names both ways to have a homeserver rather than only the local
container.

Absent a URL, every matrix path no-ops exactly as it did with no
container, and the two accessors make that structural: `Option` for the
callers that fall back to `None`, a `Result` flavour naming the skipped
`is_present()` gate for the ones that propagate.
2026-08-03 20:34:36 +02:00
atlas
bd06f81294 wip(#2860): c0re-facing matrix.apiUrl option + HIVE_MATRIX_API_URL export
Nix half of the 4th layer mara found (47535: core cannot assume matrix
is on localhost). Rust half (matrix.rs MATRIX_HTTP) NOT done.

Parked here rather than left dirty: she has redirected me to jobq as
prio 1, and uncommitted files migrate across a checkout.
2026-08-03 20:34:36 +02:00
atlas
0e9b1c563d 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:<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.
2026-08-03 20:34:36 +02:00
9 changed files with 238 additions and 119 deletions

View file

@ -132,7 +132,7 @@ setups.
```nix ```nix
hyperhive.forge.url = "http://forge.example:3000"; # default: null 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 **`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. evaluated outside a hive.
**`hyperhive.matrix.url`** — homeserver URL used by **`hyperhive.matrix.url`** — homeserver URL used by
`hive-matrix-daemon` when connecting via the matrix-sdk. Default `hive-matrix-daemon` when connecting via the matrix-sdk. hive-c0re
(`localhost:8008`) is overridden by hive-c0re at deploy time to the writes it into every agent at deploy time as the gateway-routed
gateway-routed `matrix.<domain>` URL so isolated agents can reach the `matrix.<domain>` URL, so isolated agents can reach the homeserver.
homeserver. Override per-agent when an agent should talk to a Override per-agent when an agent should talk to a different homeserver
different homeserver — for example a remote hive's tuwunel reached — for example a remote hive's tuwunel reached over a VPN, or an
over a VPN, or an external Matrix server for a federation-only agent. 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 ## Claude Code plugins

View file

@ -14,14 +14,39 @@ use reqwest::StatusCode;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
/// nspawn container name for the matrix homeserver — mirrors /// Client-server API base this daemon provisions against, from
/// `hive-forge` and matches the bare-name allow-list the lifecycle /// `HIVE_MATRIX_API_URL` (set by `hive-c0re.nix` from
/// scanner skips over. /// `hyperhive.matrix.apiUrl`).
const MATRIX_CONTAINER: &str = "hive-matrix"; ///
/// Local-host URL of the tuwunel client-server API. Shares the host /// `None` means **this hive has no homeserver to provision against** and
/// netns so `localhost:<port>` resolves both from the daemon and from /// every matrix path no-ops — see [`is_present`]. There is deliberately no
/// inside any sub-agent container. /// fallback: `localhost:8008` is right only when the homeserver happens to
const MATRIX_HTTP: &str = "http://localhost:8008"; /// 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<Option<String>> = 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()",
)
}
/// Length (bytes) of the random registration token. 32 raw bytes ⇒ /// Length (bytes) of the random registration token. 32 raw bytes ⇒
/// 64-char hex string; comfortable for a long-lived shared secret. /// 64-char hex string; comfortable for a long-lived shared secret.
const REGISTER_TOKEN_BYTES: usize = 32; const REGISTER_TOKEN_BYTES: usize = 32;
@ -107,15 +132,17 @@ pub fn hive_chat_room_id_path() -> PathBuf {
crate::paths::matrix_chat_room_id() crate::paths::matrix_chat_room_id()
} }
/// Probe whether `hive-matrix` exists as a nixos-container. Cheap — /// Whether this hive has a homeserver to provision against.
/// `nixos-container list` is just a directory scan in /etc. Same shape ///
/// as `forge::is_present` — routed through hive-priv since /// **A configured API URL, not a local container.** It used to scan
/// `nixos-container` needs root and hive-c0re runs unprivileged. /// `nixos-container list` for `hive-matrix`, which answers a different
pub async fn is_present() -> bool { /// question — "is the homeserver a container on this host" — and so made a
let Ok(stdout) = crate::priv_client::list_containers().await else { /// remote homeserver silently no-op no matter how it was addressed. The
return false; /// nix module still supplies the loopback URL whenever it runs tuwunel
}; /// itself, so a co-located hive behaves exactly as before.
stdout.lines().any(|l| l.trim() == MATRIX_CONTAINER) #[must_use]
pub fn is_present() -> bool {
matrix_http().is_some()
} }
/// Read `n` cryptographic-quality bytes from `/dev/urandom` and return /// Read `n` cryptographic-quality bytes from `/dev/urandom` and return
@ -178,7 +205,8 @@ async fn register_post(
client: &reqwest::Client, client: &reqwest::Client,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<(StatusCode, serde_json::Value)> { ) -> Result<(StatusCode, serde_json::Value)> {
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/register"); let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/register");
let resp = client let resp = client
.post(&url) .post(&url)
.json(body) .json(body)
@ -275,7 +303,8 @@ fn extract_access_token(body: &serde_json::Value) -> Result<String> {
/// in which case manual recovery via `hivectl matrix create-user` is /// in which case manual recovery via `hivectl matrix create-user` is
/// required. /// required.
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> { async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/login"); let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login");
let body = serde_json::json!({ let body = serde_json::json!({
"type": "m.login.password", "type": "m.login.password",
"identifier": { "identifier": {
@ -340,9 +369,10 @@ async fn discover_admin_room_id(
admin_token: &str, admin_token: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?;
// #admins:server → %23admins%3A<server> // #admins:server → %23admins%3A<server>
let encoded_alias = format!("%23admins%3A{server_name}"); let encoded_alias = format!("%23admins%3A{server_name}");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded_alias}"); let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -479,10 +509,11 @@ async fn admin_room_send_and_poll<T>(
command: &str, command: &str,
check: impl Fn(&str) -> Option<T>, check: impl Fn(&str) -> Option<T>,
) -> Result<T> { ) -> Result<T> {
let base = matrix_base()?;
// Send the command; record the event_id so we can use it as an anchor. // Send the command; record the event_id so we can use it as an anchor.
let txn_id = random_hex(8)?; let txn_id = random_hex(8)?;
let send_url = let send_url =
format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client let send_resp = client
.put(&send_url) .put(&send_url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -507,8 +538,7 @@ async fn admin_room_send_and_poll<T>(
// on each tick. Walk the list until we hit our own command event_id; // on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command. // everything *before* that marker arrived after our command.
let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
let poll_url = let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 { for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client let poll_json = client
@ -763,7 +793,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 /// setup in [`ensure_all`] so the rebuild path and the startup sweep
/// stay equivalent. No-op when the matrix container is absent. /// stay equivalent. No-op when the matrix container is absent.
pub async fn sync_agent_standalone(name: &str) { pub async fn sync_agent_standalone(name: &str) {
if !is_present().await { if !is_present() {
return; return;
} }
let register_token = match ensure_register_token() { let register_token = match ensure_register_token() {
@ -933,7 +963,8 @@ fn persist_password(localpart: &str, password: &str) {
/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint).
/// The response JSON always includes `"server_name"` per the matrix spec. /// The response JSON always includes `"server_name"` per the matrix spec.
pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> { pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
let url = format!("{MATRIX_HTTP}/_matrix/key/v2/server"); let base = matrix_base()?;
let url = format!("{base}/_matrix/key/v2/server");
let resp = client let resp = client
.get(&url) .get(&url)
.send() .send()
@ -995,7 +1026,8 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
/// Name-based (not alias-based) rediscovery keeps the Space free of any /// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor. /// special-char room alias — the hardcoded plain name is the anchor.
async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> { async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1012,8 +1044,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
}; };
let encoded = encode_room_id_for_url(room_id); let encoded = encode_room_id_for_url(room_id);
// Must be an m.space (m.room.create `type`). // Must be an m.space (m.room.create `type`).
let create_url = let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client let is_space = match client
.get(&create_url) .get(&create_url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1031,8 +1062,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
continue; continue;
} }
// …and named HIVE_SPACE_NAME (m.room.name `name`). // …and named HIVE_SPACE_NAME (m.room.name `name`).
let name_url = let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
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 { let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
@ -1065,6 +1095,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
/// Returns an error if the homeserver is unreachable, `createRoom` fails, /// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written. /// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> { pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
let base = matrix_base()?;
// 1. Stored room id wins (fast path). // 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
let trimmed = existing.trim().to_owned(); let trimmed = existing.trim().to_owned();
@ -1083,7 +1114,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
} }
// 3. Create the space (plain hardcoded name, no alias). // 3. Create the space (plain hardcoded name, no alias).
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let url = format!("{base}/_matrix/client/v3/createRoom");
let body = serde_json::json!({ let body = serde_json::json!({
"name": HIVE_SPACE_NAME, "name": HIVE_SPACE_NAME,
"creation_content": { "type": "m.space" }, "creation_content": { "type": "m.space" },
@ -1133,7 +1164,8 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
/// room instead of spawning a duplicate. `None` if the homeserver is /// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists. /// unreachable or no match exists.
async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> { async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1150,8 +1182,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
}; };
let encoded = encode_room_id_for_url(room_id); let encoded = encode_room_id_for_url(room_id);
// Skip the Space itself (and any other m.space). // Skip the Space itself (and any other m.space).
let create_url = let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client let is_space = match client
.get(&create_url) .get(&create_url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1169,8 +1200,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
continue; continue;
} }
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
let name_url = let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
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 { let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
@ -1196,11 +1226,11 @@ async fn set_room_state(
state_key: &str, state_key: &str,
content: &serde_json::Value, content: &serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let base = matrix_base()?;
let encoded_room = encode_room_id_for_url(room_id); let encoded_room = encode_room_id_for_url(room_id);
let encoded_key = encode_room_id_for_url(state_key); let encoded_key = encode_room_id_for_url(state_key);
let url = format!( let url =
"{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}" format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
);
let resp = client let resp = client
.put(&url) .put(&url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1243,6 +1273,7 @@ pub async fn ensure_hive_chat_room(
space_room_id: &str, space_room_id: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?;
// 1. Stored room id wins (fast path). 2. Rediscover by name before // 1. Stored room id wins (fast path). 2. Rediscover by name before
// creating (prevents duplicates after a state wipe). 3. Create. // 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()) let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path())
@ -1259,7 +1290,7 @@ pub async fn ensure_hive_chat_room(
} else { } else {
// `initial_state` is applied after the preset-derived state, so the // `initial_state` is applied after the preset-derived state, so the
// restricted join rule overrides private_chat's invite-only default. // restricted join rule overrides private_chat's invite-only default.
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let url = format!("{base}/_matrix/client/v3/createRoom");
let body = serde_json::json!({ let body = serde_json::json!({
"name": HIVE_CHAT_ROOM_NAME, "name": HIVE_CHAT_ROOM_NAME,
"topic": HIVE_CHAT_ROOM_TOPIC, "topic": HIVE_CHAT_ROOM_TOPIC,
@ -1359,11 +1390,12 @@ async fn room_membership(
encoded_room_id: &str, encoded_room_id: &str,
user_id: &str, user_id: &str,
) -> Option<String> { ) -> Option<String> {
let base = matrix_http()?;
// `:` must be percent-encoded in both the room-id and user-id path // `:` must be percent-encoded in both the room-id and user-id path
// segments; `@` and `!` are permitted path characters per RFC 3986. // segments; `@` and `!` are permitted path characters per RFC 3986.
let encoded_user = user_id.replace(':', "%3A"); let encoded_user = user_id.replace(':', "%3A");
let url = format!( let url = format!(
"{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
); );
let resp = client let resp = client
.get(&url) .get(&url)
@ -1391,6 +1423,7 @@ async fn invite_user_id(
room_id: &str, room_id: &str,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
let base = matrix_base()?;
// `:` must be percent-encoded in the room-id path segment; `!` is // `:` must be percent-encoded in the room-id path segment; `!` is
// permitted in URL path characters per RFC 3986. // permitted in URL path characters per RFC 3986.
let encoded_room_id = room_id.replace(':', "%3A"); let encoded_room_id = room_id.replace(':', "%3A");
@ -1405,7 +1438,7 @@ async fn invite_user_id(
return Ok(()); return Ok(());
} }
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1485,8 +1518,9 @@ async fn resolve_room_alias(
admin_token: &str, admin_token: &str,
alias: &str, alias: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?;
let encoded = alias.replace('#', "%23").replace(':', "%3A"); let encoded = alias.replace('#', "%23").replace(':', "%3A");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(admin_token) .bearer_auth(admin_token)
@ -1517,7 +1551,7 @@ async fn resolve_room_alias(
/// dashboard banner on persistent failure (this sweep re-runs every 30 /// dashboard banner on persistent failure (this sweep re-runs every 30
/// minutes, so a one-off blip self-heals without ever bannering). /// minutes, so a one-off blip self-heals without ever bannering).
pub async fn ensure_all() -> bool { pub async fn ensure_all() -> bool {
if !is_present().await { if !is_present() {
tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); tracing::debug!("matrix: hive-matrix container absent, skipping user sweep");
return true; return true;
} }

View file

@ -463,13 +463,19 @@ async fn handle_set_resource_limits(
)])) )]))
} }
/// Guard: matrix provisioning needs the homeserver container running. /// Guard: matrix provisioning needs a homeserver to provision against.
async fn require_matrix_present() -> Result<()> { ///
if crate::matrix::is_present().await { /// 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() {
return Ok(()); return Ok(());
} }
anyhow::bail!( anyhow::bail!(
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" "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"
) )
} }
@ -477,7 +483,7 @@ async fn handle_matrix_create_user(
name: &hive_types::Ident, name: &hive_types::Ident,
password: Option<&str>, password: Option<&str>,
) -> Result<HostResponse> { ) -> Result<HostResponse> {
require_matrix_present().await?; require_matrix_present()?;
let register_token = let register_token =
crate::matrix::ensure_register_token().context("read matrix register token")?; crate::matrix::ensure_register_token().context("read matrix register token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
@ -689,7 +695,7 @@ async fn handle_push_snapshot(
} }
async fn handle_matrix_sync_admin() -> Result<HostResponse> { async fn handle_matrix_sync_admin() -> Result<HostResponse> {
require_matrix_present().await?; require_matrix_present()?;
let register_token = let register_token =
crate::matrix::ensure_register_token().context("read matrix register token")?; crate::matrix::ensure_register_token().context("read matrix register token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
@ -707,7 +713,7 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
} }
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> { async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present().await?; require_matrix_present()?;
let admin_token = crate::matrix::read_admin_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
@ -722,7 +728,7 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
} }
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present().await?; require_matrix_present()?;
let admin_token = crate::matrix::read_admin_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
@ -742,7 +748,7 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
} }
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> { async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present().await?; require_matrix_present()?;
let admin_token = crate::matrix::read_admin_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)

View file

@ -46,13 +46,15 @@ pub struct AccountCfg {
} }
impl AccountCfg { impl AccountCfg {
/// Resolve the effective homeserver URL (per-account override or /// The effective homeserver URL — this account's own, else the
/// the daemon-wide default). /// 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] #[must_use]
pub fn homeserver(&self) -> String { pub fn homeserver(&self) -> Option<String> {
self.homeserver self.homeserver.clone().or_else(paths::homeserver_url)
.clone()
.unwrap_or_else(paths::homeserver_url)
} }
} }

View file

@ -265,7 +265,17 @@ async fn bring_up_account(
tag: Option<String>, tag: Option<String>,
is_primary: bool, is_primary: bool,
) -> Result<Option<(Client, SyncLoop)>> { ) -> Result<Option<(Client, SyncLoop)>> {
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) if !tokio::fs::try_exists(&cfg.token_file)
.await .await
.unwrap_or(false) .unwrap_or(false)

View file

@ -6,12 +6,6 @@
use std::path::PathBuf; 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 /// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`, /// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent /// 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")) PathBuf::from(format!("{state_dir}/matrix-token"))
} }
/// Resolve the homeserver URL. Override via `HIVE_MATRIX_URL`; default /// Resolve the homeserver URL from `HIVE_MATRIX_URL`, or `None` when the
/// is the in-container `localhost:8008` tuwunel. /// 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] #[must_use]
pub fn homeserver_url() -> String { pub fn homeserver_url() -> Option<String> {
std::env::var("HIVE_MATRIX_URL").unwrap_or_else(|_| DEFAULT_HOMESERVER.to_owned()) std::env::var("HIVE_MATRIX_URL").ok()
} }
/// Persistent sqlite store directory for matrix-sdk's state (event /// Persistent sqlite store directory for matrix-sdk's state (event

View file

@ -11,12 +11,6 @@
}: }:
let let
userName = config.hyperhive.user.name; 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 # 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 # 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). # over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
@ -36,13 +30,13 @@ in
When true (the default), the harness: When true (the default), the harness:
- runs `hive-matrix-daemon` as a systemd unit that holds a - runs `hive-matrix-daemon` as a systemd unit that holds a
matrix-sdk Client + sync against the homeserver at matrix-sdk Client + sync against the homeserver named by
`HIVE_MATRIX_URL` (default `http://localhost:8008` the `HIVE_MATRIX_URL` (see `hyperhive.matrix.url` there is no
in-host tuwunel from `nix/host-modules/hive-matrix.nix`). The default, since an agent's own netns makes a loopback guess
daemon auto-skips when `<state>/matrix-token` is missing, wrong). The daemon auto-skips when that URL or
and a `systemd.paths` watcher restarts it the moment `<state>/matrix-token` is missing, and a `systemd.paths`
hive-c0re provisions the token (same path-trigger shape watcher restarts it the moment hive-c0re provisions the token
as `forge-avatar-sync`). (same path-trigger shape as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm, - exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms, send_reaction, send_reply, mark_read, list_rooms,
list_room_members, read_room) to claude via an auto-injected list_room_members, read_room) to claude via an auto-injected
@ -63,17 +57,27 @@ in
}; };
options.hyperhive.matrix.url = lib.mkOption { options.hyperhive.matrix.url = lib.mkOption {
type = lib.types.str; type = lib.types.nullOr lib.types.str;
default = matrixUrlDefault; default = null;
example = "https://matrix.darkest.space"; example = "https://matrix.darkest.space";
description = '' description = ''
Matrix homeserver URL the agent's `hive-matrix-daemon` connects Matrix homeserver URL the agent's `hive-matrix-daemon` connects
to. At runtime hive-c0re forwards the isolation-aware URL to. hive-c0re writes this per agent from the hive's own
(`matrix.<domain>` via the gateway) so isolated agents reach isolation-aware URL (`matrix.<domain>` via the gateway), so a
the homeserver without crossing host loopback. Override generated agent config always carries a real value; set it by
per-agent when an agent should talk to an external homeserver hand only when an agent should talk to an external homeserver
instead (e.g. a federation-only setup or a remote hive's instead (a federation-only setup, or a remote hive's tuwunel
tuwunel reached via a vpn). 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"; HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; RUST_LOG = "info";
} }
# Homeserver URL: by default the daemon inherits the host-forwarded # Homeserver URL. hive-c0re writes this option per agent from the
# HIVE_MATRIX_URL (set by hive-c0re to `matrix.<domain>` via the # hive's own `matrix.<domain>` gateway URL (agents run in a private
# gateway, since agents run in private netns and can't reach host # netns and cannot reach host loopback), so on a real hive it is
# loopback directly), falling back to the daemon's built-in # always set; `null` is the honest "this agent has no homeserver"
# localhost default if the forward is absent. A per-agent # and leaves the daemon without one, which it treats like a missing
# `hyperhive.matrix.url` override (non-default) is set unit-level # token and no-ops. Nothing here falls back to loopback: that would
# so it wins over the forwarded value; at the default we # be a value that evaluates fine and then addresses the agent's own
# deliberately DON'T set it so the forwarded value isn't shadowed. # netns instead of the homeserver.
// lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) { // lib.optionalAttrs (config.hyperhive.matrix.url != null) {
HIVE_MATRIX_URL = config.hyperhive.matrix.url; HIVE_MATRIX_URL = config.hyperhive.matrix.url;
} }
# Multi-account: serialize the *extra* accounts to the JSON the # Multi-account: serialize the *extra* accounts to the JSON the

View file

@ -100,18 +100,38 @@ in
# gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`. # gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`.
HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}"; 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 lib.optionalAttrs
# hive-matrix-daemon — the gateway vhost (`matrix.<domain>`). The (config.services.hyperhive.matrix.enable && config.services.hyperhive.matrix.gatewayHost != null)
# gatewayHost null-guard falls back to loopback so a domain-less {
# config still evals. Forwarded to agents by meta.rs alongside # In-cluster matrix homeserver URL for each agent's
# HIVE_FORGE_URL; shares the same env-forwarding ordering caveat # hive-matrix-daemon — the gateway vhost (`matrix.<domain>`).
# (value baked at config-generation time). # Forwarded to agents by meta.rs alongside HIVE_FORGE_URL; shares the
HIVE_MATRIX_URL = # same env-forwarding ordering caveat (value baked at
if config.services.hyperhive.matrix.gatewayHost != null then # config-generation time).
"http://${config.services.hyperhive.matrix.gatewayHost}" #
else # A domain-less config forwards nothing rather than falling back to
"http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}"; # 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.gui.enable { // lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
# Availability flags read by the dashboard's `/api/state`. # Availability flags read by the dashboard's `/api/state`.

View file

@ -147,6 +147,37 @@ 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 { gatewayHost = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = "matrix.${hyperhiveDomain}"; default = "matrix.${hyperhiveDomain}";