Watch
0
0
Fork
You've already forked hyperhive
0

stop provisioning the hive-chat room in the matrix sweep

This commit is contained in:
damocles 2026-10-28 20:39:15 +01:00
commit c7f3706597
15 changed files with 37 additions and 371 deletions

View file

@ -116,7 +116,7 @@ optionally to rooms:
```bash
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
hivectl matrix invite @mara:yourserver --room '#some-room:yourserver'
```
## 6 · Your first agent

View file

@ -97,7 +97,7 @@ through the gateway; from outside the host that takes
`gateway.openFirewall`.
hive-c0re provisions matrix (its own sender login, the hive Space and
chat room, invites) through `swarm.matrix.apiUrl`, which defaults to
invites) through `swarm.matrix.apiUrl`, which defaults to
`https://<swarm.matrix.gatewayHost>` on every hive. That login carries
the hive's matrix access token, and on a hive that doesn't run the
homeserver it crosses the network, so the default is TLS; hive-c0re
@ -259,9 +259,8 @@ authenticates as — an account the swarm's appservice creates — rather
than any privilege level, because it carries none.
It needs no promotion for what the hive does with it. Creating the hive
Space and the chat room, writing their hierarchy and join rules, and
inviting agents into them are all ordinary client calls that ride on
being the rooms' own creator at power level 100 — there is no homeserver
Space and inviting agents into it are ordinary client calls that ride on
being the Space's own creator at power level 100 — there is no homeserver
admin in any of it, and no Synapse admin API to reach for either, since
tuwunel has none.
@ -355,28 +354,19 @@ into it. This gives the operator a single Space in FluffyChat or any
Matrix client that groups all agent-to-agent + operator rooms in one
place.
<!-- vale write-good.Passive = NO -->
The sweep also provisions a default **`hive-chat` room** as an
`m.space.child` of the Space. Joining a Space doesn't autojoin
child rooms — the explicit room entry ensures the operator and every
agent can find a common chat room without manual setup. Room join is
restricted (any Space member including the operator can join; agents
are explicitly invited). Room version pinned to 10 for the restricted
join floor.
<!-- vale write-good.Passive = YES -->
**State**: hive-c0re persists the Space's room ID to
`/var/lib/hyperhive/matrix/space-room-id` (mode `0600`, owned by the
hive-c0re service user). The path is **outside** every agent state dir and
**isn't** deleted by `nixos-container destroy --purge`, so it survives full
agent purges, and hive-c0re reuses it on re-provision.
**State**: hive-c0re persists both room IDs to `/var/lib/hyperhive/matrix/`
(mode `0600`, owned by the hive-c0re service user):
- `space-room-id` — the Space itself
- `chat-room-id` — the `hive-chat` room
An earlier version also provisioned a `hive-chat` room and kept its id in
`chat-room-id`. The sweep no longer touches either; an existing room and
file are harmless leftovers that can be removed by hand.
These paths are **outside** every agent state dir and **aren't** deleted by
`nixos-container destroy --purge` — both survive full agent purges, and
hive-c0re reuses them on re-provision.
**Idempotent**: if the files exist and are non-empty, hive-c0re considers the Space and
room already created. Delete the files to force
re-creation (for example after a homeserver wipe).
**Idempotent**: if the file exists and is non-empty, hive-c0re considers the
Space already created. Delete the file to force re-creation (for example
after a homeserver wipe).
## Configuration tuning

View file

@ -116,7 +116,7 @@ Always prints the diff first. `--from forge` resets the local checkout to forge
matrix-tuwunel invites.
Manual invites into the hive Space and its rooms; c0re's periodic matrix sweep provisions the Space, the chat room and the agents' invites on its own.
Manual invites into the hive Space and its rooms; c0re's periodic matrix sweep provisions the Space and the agents' invites on its own.
**Usage:** `hivectl matrix <COMMAND>`

View file

@ -50,7 +50,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
```bash
hivectl matrix invite mara # invite a user to the hive Space
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
hivectl matrix invite @mara:server --room '#some-room:server' # ...or to a specific room/alias
```
Human matrix accounts come from SSO, not `hivectl`; matrix homeserver

View file

@ -412,8 +412,7 @@ fn matrix_sweep_warning(ctx: sweep_health::SweepFailure) -> String {
);
format!(
"matrix user/space sweep failing ({} consecutive, {age}) \
— some agents may be missing matrix accounts, space membership, \
or chat-room invites",
— some agents may be missing matrix accounts or space membership",
ctx.consecutive
)
}

View file

@ -32,7 +32,7 @@ pub enum Resource {
/// `.git/index.lock` and the remote-tracking ref lock.
KnowledgeTree,
/// The hive's matrix provisioning, held by every `MatrixSweep`. Two
/// overlapping passes on a hive with no persisted Space / chat-room id both
/// overlapping passes on a hive with no persisted Space id both
/// miss the by-name lookup and both `createRoom`, leaving a duplicate room
/// with agents invited to both.
MatrixSweep,

View file

@ -308,7 +308,7 @@ async fn cmd_serve(
}
});
// Matrix sweep (`matrix::ensure_all`): the hive's own account, the hive
// Space and chat room, and every agent container's invite to both. It
// Space, and every agent container's invite to it. It
// creates no agent accounts or tokens — swarm-controller mints those.
// No-op when the hive-matrix container isn't running. Re-submitted every
// 30 minutes; the startup pass is the boot `MatrixSweep` node.

View file

@ -1,5 +1,5 @@
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
//! taken from the swarm secret store, and the hive Space, chat room and
//! taken from the swarm secret store, and the hive Space and
//! invites provisioned with it. No-op when no homeserver is configured, so
//! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
//! true` pay nothing.
@ -60,8 +60,8 @@ fn matrix_base() -> Result<&'static str> {
/// [`stored_sender_token`] reads it; hive-c0re never creates it.
///
/// An ordinary account, with no homeserver-admin standing: what it
/// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms.
/// provisions — the Space and the invites — it provisions as the
/// creator of the Space.
///
/// # Errors
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
@ -86,16 +86,6 @@ pub fn hive_localpart() -> Result<String> {
/// being created on the next sweep.
pub const HIVE_SPACE_NAME: &str = "hive";
/// Display name of the default "hive chat" room — the `m.space.child` of
/// the hive Space that every agent + the operator can join. Plain text so
/// it stays rediscoverable by name (mirrors [`HIVE_SPACE_NAME`]) when the
/// persisted room-id file is lost, preventing duplicate chat rooms.
pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
/// Topic for the default hive chat room.
const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
/// Host path for the appservice sender account's matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use]
@ -110,13 +100,6 @@ pub fn hive_space_room_id_path() -> PathBuf {
crate::paths::matrix_space_room_id()
}
/// Host path where the default hive chat room ID is persisted. Outside
/// every purgeable path — not deleted by `destroy --purge`.
#[must_use]
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**: whether the homeserver
@ -408,233 +391,6 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
Ok(room_id)
}
/// Persist the hive chat room id to [`hive_chat_room_id_path()`] (0600).
fn persist_chat_room_id(room_id: &str) -> Result<()> {
let path = hive_chat_room_id_path();
crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes())
.with_context(|| format!("matrix: write chat room_id to {}", path.display()))
}
/// Scan the admin account's joined rooms for the canonical hive chat room:
/// a non-space room named [`HIVE_CHAT_ROOM_NAME`]. Mirrors
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
/// 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, sender_token: &str) -> Option<String> {
find_joined_room(
client,
matrix_http()?,
sender_token,
false,
HIVE_CHAT_ROOM_NAME,
)
.await
}
/// Whether the state event still has to be written.
///
/// `current` is the room's existing content for this `(type, state_key)`,
/// or `None` when it could not be read. **Unreadable means write**: the
/// re-apply exists so a missing link gets repaired, and one redundant
/// event is cheaper than a hierarchy that never reconverges.
///
/// Comparison is `serde_json::Value` equality, which compares objects as
/// maps — key order in the response does not matter.
fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::Value) -> bool {
current != Some(desired)
}
/// Read the room's current content for one state event. `None` on any
/// failure — absent state, transport error, a refusal, or an unparseable body
/// are all "we don't know", and [`state_needs_write`] turns that into a write.
///
/// A failure other than absent state is logged at `warn`: **a read that breaks
/// while the write still works** makes the guard degrade to writing every
/// time, and the sweep resumes waking the hive with nothing else to show for
/// it. An absent state event (404) is the expected first-setup case.
async fn current_room_state(
client: &reqwest::Client,
base: &str,
sender_token: &str,
room_id: &str,
event_type: &str,
state_key: &str,
) -> Option<serde_json::Value> {
match swarm_matrix_client::room_state(
client,
base,
sender_token,
room_id,
event_type,
state_key,
)
.await
{
Ok(content) => content,
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
%room_id,
event_type,
"matrix: state read failed while writes still work — the \
skip-if-unchanged guard is off and every sweep will re-emit"
);
None
}
}
}
/// PUT a state event into `room_id` using the sender token, **skipping the
/// write when the room already carries identical content**.
///
/// The read is not an optimisation. A PUT of identical content is a no-op
/// on the room's *state*, and the homeserver still appends an event to the
/// *timeline* — so "idempotent" was true one level too high. Downstream,
/// an event is unread activity, which is a todo, which is a turn: a caller
/// re-applying a link on a periodic sweep wakes every agent in the room on
/// that sweep's cadence, forever. Measured at ~30 minutes per wake per
/// agent before this guard existed.
///
/// The self-healing property the re-apply exists for is unaffected: a
/// missing or divergent link still gets written.
async fn set_room_state(
client: &reqwest::Client,
sender_token: &str,
room_id: &str,
event_type: &str,
state_key: &str,
content: &serde_json::Value,
) -> Result<()> {
let base = matrix_base()?;
let current =
current_room_state(client, base, sender_token, room_id, event_type, state_key).await;
if !state_needs_write(current.as_ref(), content) {
tracing::debug!(
%room_id,
event_type,
"matrix: state already current, skipping PUT (no timeline event)"
);
return Ok(());
}
swarm_matrix_client::put_state(
client,
base,
sender_token,
room_id,
event_type,
state_key,
content,
)
.await
.context("matrix: set room state")
}
/// Create (or recover) the default hive chat room and wire it as a child of
/// the hive Space, persisting its room id to [`hive_chat_room_id_path()`].
///
/// The room is a normal room (not an `m.space`) named [`HIVE_CHAT_ROOM_NAME`]
/// with a `restricted` join rule allowing any member of the hive Space to
/// join — so the operator (who is in the Space) and every agent can chat
/// without needing an explicit invite. It's linked bidirectionally to the
/// Space: `m.space.child` on the Space points at the room, `m.space.parent`
/// on the room points back. Joining a Space does NOT auto-join its children
/// (Matrix semantics), so this gives clients a concrete room to surface +
/// join instead of an empty Space.
///
/// Dedup mirrors [`ensure_hive_space`]: persisted id wins, else rediscover
/// by name, else create. The space-child link is re-checked on every call
/// so a recovered room reconverges its hierarchy link — and written only
/// when it differs, see [`set_room_state`].
///
/// # Errors
///
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written. A failure wiring the space-child
/// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room(
client: &reqwest::Client,
sender_token: &str,
space_room_id: &str,
server_name: &str,
) -> Result<String> {
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())
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
{
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id
} else {
// `initial_state` is applied after the preset-derived state, so the
// restricted join rule overrides private_chat's invite-only default.
let body = serde_json::json!({
"name": HIVE_CHAT_ROOM_NAME,
"topic": HIVE_CHAT_ROOM_TOPIC,
// Pin the room version explicitly: the `restricted` join rule
// below needs room version >= 8. tuwunel's default is currently
// higher, but pinning makes the dependency explicit so a future
// homeserver-default change can't silently invalidate the
// restricted rule (which would quietly fall back to invite-only
// and break the "operator joins from the Space" path).
"room_version": "10",
"preset": "private_chat",
"visibility": "private",
"initial_state": [
{
"type": "m.room.join_rules",
"state_key": "",
"content": {
"join_rule": "restricted",
"allow": [
{ "type": "m.room_membership", "room_id": space_room_id }
]
}
},
{
"type": "m.space.parent",
"state_key": space_room_id,
"content": { "via": [server_name], "canonical": true }
}
]
});
let id = swarm_matrix_client::create_room(client, base, sender_token, &body)
.await
.context("matrix: create hive chat room")?;
persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: created hive chat room");
id
};
// Wire the Space → room child link. Without an `m.space.child` carrying
// a `via`, the room won't surface in the Space hierarchy. `suggested`
// hints clients to surface it prominently.
let child_content = serde_json::json!({
"via": [server_name],
"suggested": true,
});
if let Err(e) = set_room_state(
client,
sender_token,
space_room_id,
"m.space.child",
&room_id,
&child_content,
)
.await
{
tracing::warn!(error = ?e, "matrix: set m.space.child on hive space failed");
}
Ok(room_id)
}
/// Invite `@{localpart}:{server_name}` to `room_id` using the admin
/// account. Idempotent — treats already-member responses as success.
async fn invite_to_room(
@ -777,7 +533,7 @@ pub async fn invite_user(
}
/// Make sure the hive's own `@hive-<hive>:` account exists, then provision
/// the hive Space and chat room and invite every agent container to both.
/// the hive Space and invite every agent container to it.
/// Agents' own accounts are not created here: `swarm-controller` mints them
/// with the swarm's appservice token. Runs only as a `MatrixSweep` job node
/// (see `job_queue::templates::matrix_sweep`). No-op when the
@ -804,9 +560,8 @@ pub async fn ensure_all() -> bool {
}
};
// The hive's `@hive-<hive>:` account first, because everything below provisions
// THROUGH it (the Space, the chat room and every invite are sent with
// its token) — as an ordinary user that created those rooms, not as a
// homeserver admin.
// THROUGH it (the Space and every invite are sent with its token) — as
// an ordinary user that created the Space, not as a homeserver admin.
if let Err(e) = ensure_hive_user().await {
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
ok = false;
@ -829,8 +584,8 @@ pub async fn ensure_all() -> bool {
ok
}
/// Provision the hive Space + default chat room and invite every agent
/// (+ the admin account) to both. Split out of [`ensure_all`] purely to
/// Provision the hive Space and invite every agent (+ the admin account)
/// to it. Split out of [`ensure_all`] purely to
/// keep that function under the `too_many_lines` threshold — this is the
/// tail half of the same sequential sweep and shares its aggregate-bool,
/// log-and-continue failure handling.
@ -878,40 +633,6 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
ok = false;
}
}
// Provision the default hive chat room as an m.space.child of the Space
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy.
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
Ok(chat_room_id) => {
if let Err(e) = invite_to_room(
client,
&sender_token,
&chat_room_id,
&localpart,
&server_name,
)
.await
{
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
ok = false;
}
for name in agent_names {
if let Err(e) =
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
{
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false;
}
}
}
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_chat_room failed");
ok = false;
}
}
ok
}
@ -956,43 +677,6 @@ mod tests {
assert_eq!(sender_source(None, None), SenderSource::Unavailable);
}
/// The steady state. This is the whole point of the guard: the sweep
/// runs forever, and every write it makes is a wake for every agent
/// in the room.
#[test]
fn state_matching_the_room_is_not_rewritten() {
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
let current = desired.clone();
assert!(!state_needs_write(Some(&current), &desired));
}
/// Key order in the homeserver's response must not force a write —
/// the guard leans on `serde_json::Value` comparing objects as maps,
/// and a byte- or order-sensitive comparison would silently degrade
/// to writing every time while still looking correct.
#[test]
fn state_matching_but_reordered_is_not_rewritten() {
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
let current = serde_json::json!({ "suggested": true, "via": ["example.org"] });
assert!(!state_needs_write(Some(&current), &desired));
}
#[test]
fn diverged_state_is_rewritten() {
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
let current = serde_json::json!({ "via": ["old.example.org"], "suggested": true });
assert!(state_needs_write(Some(&current), &desired));
}
/// Fail-open, and deliberately so: an unreadable current state must
/// write. The re-apply exists to repair a missing link, so "we don't
/// know" has to behave like "it's missing", not like "it's fine".
#[test]
fn unknown_state_is_written() {
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
assert!(state_needs_write(None, &desired));
}
/// A banned target or a sender without power gets a 403; with no
/// membership behind it, the invite did not happen.
#[test]

View file

@ -110,13 +110,6 @@ pub fn matrix_space_room_id() -> PathBuf {
matrix_dir().join("space-room-id")
}
/// `matrix/chat-room-id` — persisted default "hive chat" room id (the
/// `m.space.child` of the hive Space every agent + the operator can join).
#[must_use]
pub fn matrix_chat_room_id() -> PathBuf {
matrix_dir().join("chat-room-id")
}
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
#[must_use]
pub fn run_dir() -> PathBuf {

View file

@ -39,8 +39,8 @@ pub enum Cmd {
/// matrix-tuwunel invites.
///
/// Manual invites into the hive Space and its rooms; c0re's periodic
/// matrix sweep provisions the Space, the chat room and the agents'
/// invites on its own.
/// matrix sweep provisions the Space and the agents' invites on
/// its own.
Matrix {
#[command(subcommand)]
cmd: MatrixCmd,

View file

@ -170,7 +170,7 @@ in
}
// lib.optionalAttrs (config.services.hyperhive.swarm.matrix.apiUrl != null) {
# Client-server API base hive-c0re uses to provision matrix (register
# agent users, create the hive space + chat room, invite members).
# agent users, create the hive space, invite members).
# Supplied by `services.hyperhive.swarm.matrix.apiUrl`, which defaults to
# the same gateway vhost as HIVE_MATRIX_URL above, over https because it
# carries the hive's matrix token and may cross the network. Its own

View file

@ -59,8 +59,8 @@ in
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
provision matrix (register agent users, create the hive space,
invite members). Distinct from the agent-facing
`services.hyperhive.agent.matrix.url`, which is the gateway vhost handed to
each agent's `hive-matrix-daemon`.

View file

@ -913,8 +913,8 @@ in
# the swarm appservice's sender. Its token is read by
# matrix-ctl and swarm-controller only. Each `@hive-<hive>`
# stays an ordinary user, because its hive reads its
# credential; what it does (the Space, the chat room, the
# invites) rides on being the rooms' creator, not on admin.
# credential; what it does (the Space and the invites)
# rides on being the Space's creator, not on admin.
#
# The registration load creates the sender inside
# `Services::start()`, and `admin_execute` runs after that and

View file

@ -15,7 +15,7 @@ device's token instead of adding a device.
the operator names. Neither call is an appservice call, so neither carries the
appservice token.
`hive-c0re` provisions the hive Space and chat room with the room calls
`hive-c0re` provisions the hive Space with the room calls
(`joined_rooms`, `room_state`, `put_state`, `create_room`, `invite`,
`resolve_alias`), and `hive-matrix-mcp` recovers a session's user and device id
with `whoami_session`. Each takes the caller's `reqwest::Client`, so the caller

View file

@ -1,7 +1,7 @@
//! The calls against a Matrix homeserver's client-server API shared by the
//! swarm and the hive: the appservice calls (register, log in, ask a token who
//! it is), the room calls `swarm-controller`'s admin-room commands and
//! `hive-c0re`'s Space and chat-room provisioning need (resolve an alias, list
//! `hive-c0re`'s Space provisioning need (resolve an alias, list
//! members and joined rooms, read and set room state, create a room, invite, send
//! a message, check an account exists), and the password login and logout
//! `swarm-controller` runs for an operator-linked account on any homeserver.