Watch
0
0
Fork
You've already forked hyperhive
0

also stop provisioning the hive space and make hivectl matrix invite take a room

This commit is contained in:
damocles 2026-10-28 20:57:41 +01:00
commit fd01848d4d
20 changed files with 83 additions and 437 deletions

View file

@ -111,12 +111,11 @@ container keeps starting with the hive meanwhile.
## 5 · Matrix
Your matrix account comes from SSO. Invite it to the hive Space, and
optionally to rooms:
Your matrix account comes from SSO. Invite it to the rooms you want it in:
```bash
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#some-room:yourserver'
hivectl matrix invite mara --room '#some-room:yourserver'
hivectl matrix invite @mara:yourserver --room '!abc:yourserver'
```
## 6 · Your first agent

View file

@ -96,8 +96,7 @@ and federation peers all reach the homeserver at `chat.<swarm-domain>`
through the gateway; from outside the host that takes
`gateway.openFirewall`.
hive-c0re provisions matrix (its own sender login, the hive Space and
invites) through `swarm.matrix.apiUrl`, which defaults to
hive-c0re talks to matrix (its own sender login, 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
@ -258,9 +257,8 @@ token and gets a refusal on any other hive's. The name says what it
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 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
It needs no promotion for what the hive does with it. Inviting a user into a
room it has the power in is an ordinary client call — there is no homeserver
admin in any of it, and no Synapse admin API to reach for either, since
tuwunel has none.
@ -346,27 +344,17 @@ Initial rollout settings:
stored yet the account comes up without backup and picks it up on the
next daemon start. A failure is a `WARN` and never stops the daemon.
## Hive Matrix Space
## Hive sender token
On first boot hive-c0re creates a private **Matrix Space** named `"hive"` using its own hive
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
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.
hive-c0re keeps the hive's `@hive-<hive>:` sender token on disk for
`hivectl matrix invite`, refreshed from the swarm secret store by the
matrix sweep (at boot, then every 30 minutes).
**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.
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, so delete them by hand if you like.
**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).
Earlier versions also created a private "hive" Matrix Space and a
`hive-chat` room, invited every agent to both, and kept their ids in
`/var/lib/hyperhive/matrix/space-room-id` and `chat-room-id`. The sweep no
longer touches any of them. The rooms and files are harmless leftovers, so
delete them by hand if you like.
## Configuration tuning

View file

@ -73,7 +73,7 @@ Cheap — no build slot:
| `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
| `ForgeSweep` | one-shot boot-time publish of the hive's forge read token for nix (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
| `MatrixSweep` | matrix user/space sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless |
| `MatrixSweep` | matrix sender-token sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless |
| `KnowledgePull` | `/knowledge` pull (`knowledge::pull`): at boot (commits that landed while `hive-c0re` was down), on the swarm knowledge-changed event, and hourly as a fallback. Holds `Resource::KnowledgeTree` (capacity 1), so two pulls never overlap on the working tree; each trigger queues its own pass, which waits for the resource if one is already live. Agentless |
| `WantedPull` | pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. Runs at boot, waiting up to a minute in total for the queue connection, and again after every attach of the wanted-state watch (which retries with a backoff when the attach fails or the stream ends); the deploy event (`swarm_status`) and the watch are the fast path, this repairs a missed one. A first deploy already queued or running for an agent isn't queued again. Agentless |

View file

@ -116,21 +116,21 @@ 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 and the agents' invites on its own.
Manual invites into a room by id or alias.
**Usage:** `hivectl matrix <COMMAND>`
###### **Subcommands:**
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
* `invite` — Invite a matrix user to a room. Idempotent
## `hivectl matrix invite`
Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
Invite a matrix user to a room. Idempotent
**Usage:** `hivectl matrix invite [OPTIONS] <USER>`
**Usage:** `hivectl matrix invite --room <ROOM> <USER>`
###### **Arguments:**
@ -138,7 +138,7 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo
###### **Options:**
* `--room <ROOM>` — Target room id (`!abc:server`) or alias (`#name:server`). Omit to invite to the hive Space
* `--room <ROOM>` — Target room id (`!abc:server`) or alias (`#name:server`)

View file

@ -44,13 +44,12 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not
## Matrix
Manual entry to the same idempotent matrix provisioning flow
`hive-c0re` runs at boot. Requires the `hive-matrix` container to be
Manual invites into a room. Requires the `hive-matrix` container to be
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 '#some-room:server' # ...or to a specific room/alias
hivectl matrix invite mara --room '#some-room:server' # a room alias
hivectl matrix invite @mara:server --room '!abc:server' # ...or a room id
```
Human matrix accounts come from SSO, not `hivectl`; matrix homeserver
@ -58,10 +57,9 @@ admin should eventually come from membership in authelia's `admins`
group; nobody has built that sync yet.
- `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the sender
token; the account must be a member of the target room with
invite power (it owns the hive Space, so that case always works).
localpart, qualified with the homeserver's `server_name`) to the
`--room` id / `#alias`. Uses the sender token; the account must be a
member of the target room with invite power.
Idempotent — already-member / already-invited is a no-op.
## Agents

View file

@ -106,7 +106,7 @@ pub(super) trait Host: Sync {
fn purge_dirs(&self, name: &str) -> Vec<PathBuf>;
/// Publish the hive's forge credential for nix.
async fn forge_ensure_all(&self);
/// One matrix user/space pass; whether every agent synced.
/// One matrix sender-token pass; whether the token is in place.
async fn matrix_ensure_all(&self) -> bool;
/// One `/knowledge` pull.
async fn knowledge_pull(&self, coord: &Arc<Coordinator>) -> Result<()>;
@ -367,7 +367,7 @@ async fn run_forge_sweep(host: &impl Host) -> Result<()> {
Ok(())
}
/// Matrix user/space sweep as a DAG node — see [`NodeKind::MatrixSweep`].
/// Matrix sender-token sweep as a DAG node — see [`NodeKind::MatrixSweep`].
/// Every pass reports here, whoever submitted it: as the node's own error on
/// the dashboard, and into the debounced warning.
async fn run_matrix_sweep(host: &impl Host, deadline: std::time::Duration) -> Result<()> {
@ -411,8 +411,8 @@ fn matrix_sweep_warning(ctx: sweep_health::SweepFailure) -> String {
|d| format!("last ok {} ago", sweep_health::fmt_age(d)),
);
format!(
"matrix user/space sweep failing ({} consecutive, {age}) \
— some agents may be missing matrix accounts or space membership",
"matrix sender-token sweep failing ({} consecutive, {age}) \
— `hivectl matrix invite` may not work",
ctx.consecutive
)
}

View file

@ -99,7 +99,7 @@ pub enum NodeKind {
/// One-shot boot-time publish of the hive's forge read token for nix
/// (`forge::ensure_all`). Agentless.
ForgeSweep,
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
/// Matrix sender-token sweep (`matrix::ensure_all`): at boot and every 30
/// minutes. Agentless.
MatrixSweep,
/// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm

View file

@ -31,10 +31,8 @@ pub enum Resource {
/// overlapping `reset` / `clean` / `pull` passes there fail on
/// `.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 id both
/// miss the by-name lookup and both `createRoom`, leaving a duplicate room
/// with agents invited to both.
/// The hive's sender-token file, held by every `MatrixSweep`. Two
/// overlapping passes would race on writing it.
MatrixSweep,
}

View file

@ -370,7 +370,7 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
// `Reconcile` root per drifted agent) — no anchor node and no per-agent child
// DAGs.
/// One matrix user/space sweep. Holding [`Resource::MatrixSweep`] is what keeps
/// One matrix sender-token sweep. Holding [`Resource::MatrixSweep`] is what keeps
/// two passes from overlapping, whichever caller submitted each.
pub fn matrix_sweep(builder: &JobBuilder) -> Handle<'_> {
builder

View file

@ -307,9 +307,9 @@ async fn cmd_serve(
}
}
});
// Matrix sweep (`matrix::ensure_all`): the hive's own account, the hive
// Space, and every agent container's invite to it. It
// creates no agent accounts or tokens — swarm-controller mints those.
// Matrix sweep (`matrix::ensure_all`): the hive's own sender token, kept
// on disk for `hivectl matrix invite`. It
// creates no 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.
let mut matrix_shutdown = coord.shutdown_rx();

View file

@ -1,8 +1,7 @@
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
//! 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.
//! taken from the swarm secret store and kept on disk for `hivectl matrix
//! invite`. No-op when no homeserver is configured, so operators who haven't
//! flipped `services.hyperhive.deploy.matrix.enable = true` pay nothing.
//!
//! This module creates no accounts: `swarm-controller` mints every hive's
//! sender account and every agent's account with the swarm's appservice token.
@ -46,46 +45,6 @@ fn matrix_base() -> Result<&'static str> {
)
}
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
///
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
/// homeserver could not attribute an action to a hive and revoking one hive's
/// standing revoked all of them.
///
/// `swarm-controller` creates this account with the swarm's appservice token
/// (`matrix_account::hive_sender`) and stores its token where
/// [`stored_sender_token`] reads it; hive-c0re never creates it.
///
/// An ordinary account, with no homeserver-admin standing: what it
/// 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
/// [`stored_sender_token`] logs in with and the swarm meta flake asserts
/// for every hive, so its absence is a broken deployment rather than a
/// supported one — and there is no safe default to fall back to: the only one
/// available is the shared account this replaced.
pub fn hive_localpart() -> Result<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())
.context(
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
— the localpart is derived from the hive name and has no swarm-wide fallback",
)?;
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
}
/// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when
/// the persisted room-id file is lost — preventing duplicate spaces from
/// being created on the next sweep.
pub const HIVE_SPACE_NAME: &str = "hive";
/// 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]
@ -93,13 +52,6 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token()
}
/// Host path where the hive Matrix Space room ID is persisted.
/// Outside every purgeable path — not deleted by `destroy --purge`.
#[must_use]
pub fn hive_space_room_id_path() -> PathBuf {
crate::paths::matrix_space_room_id()
}
/// Whether this hive has a homeserver to provision against.
///
/// **A configured API URL, not a local container**: whether the homeserver
@ -274,136 +226,6 @@ pub fn read_sender_token() -> Result<String> {
})
}
/// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600).
fn persist_space_room_id(room_id: &str) -> Result<()> {
let path = hive_space_room_id_path();
crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes())
.with_context(|| format!("matrix: write space room_id to {}", path.display()))
}
/// Walk the admin account's joined rooms at `base` and return the first room
/// named `name` that is an `m.space` exactly when `want_space` is set (the
/// `m.room.create` `type`). `None` if the homeserver is unreachable or no
/// room matches.
async fn find_joined_room(
client: &reqwest::Client,
base: &str,
sender_token: &str,
want_space: bool,
name: &str,
) -> Option<String> {
let rooms = swarm_matrix_client::joined_rooms(client, base, sender_token)
.await
.ok()?;
for room_id in rooms {
// m.room.create `type` says whether this is a Space.
let is_space = keyless_state(client, base, sender_token, &room_id, "m.room.create")
.await
.is_some_and(|c| c["type"].as_str() == Some("m.space"));
if is_space != want_space {
continue;
}
// …and named `name` (m.room.name `name`).
let name_matches = keyless_state(client, base, sender_token, &room_id, "m.room.name")
.await
.is_some_and(|n| n["name"].as_str() == Some(name));
if name_matches {
return Some(room_id);
}
}
None
}
/// The content of a state event with an empty state key, or `None` when the
/// room has none or the read failed.
async fn keyless_state(
client: &reqwest::Client,
base: &str,
sender_token: &str,
room_id: &str,
event_type: &str,
) -> Option<serde_json::Value> {
swarm_matrix_client::room_state(client, base, sender_token, room_id, event_type, "")
.await
.ok()
.flatten()
}
/// Scan the admin account's joined rooms for the canonical hive Space: the
/// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match
/// (deterministic per the homeserver's joined-rooms order) so a lost
/// room-id file recovers the existing space instead of spawning a
/// duplicate. `None` if the homeserver is unreachable or no match exists.
///
/// 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, sender_token: &str) -> Option<String> {
find_joined_room(client, matrix_http()?, sender_token, true, HIVE_SPACE_NAME).await
}
/// Create (or recover) the hive Matrix Space and persist its room ID to
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
///
/// Dedup strategy (single canonical space):
/// 1. If the room-id file exists, reuse it.
/// 2. Otherwise, rediscover by scanning the admin's joined rooms for the
/// `m.space` named [`HIVE_SPACE_NAME`] and adopt it (re-persisting the
/// file). This recovers the existing space after a state wipe instead
/// of creating a duplicate.
/// 3. Only if neither yields a room do we `createRoom`.
///
/// # Errors
///
/// 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, sender_token: &str) -> Result<String> {
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();
if !trimmed.is_empty() {
tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned");
return Ok(trimmed);
}
}
// 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = find_space_by_name(client, sender_token).await {
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id);
}
// 3. Create the space (plain hardcoded name, no alias).
let body = serde_json::json!({
"name": HIVE_SPACE_NAME,
"creation_content": { "type": "m.space" },
"preset": "private_chat",
"visibility": "private",
});
let room_id = swarm_matrix_client::create_room(client, base, sender_token, &body)
.await
.context("matrix: create hive space")?;
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: created hive space");
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(
client: &reqwest::Client,
sender_token: &str,
room_id: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, sender_token, room_id, &user_id).await
}
/// Fetch a user's current membership in a room via the sender token, or
/// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
@ -484,23 +306,21 @@ fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> R
)
}
/// Invite an arbitrary Matrix user to the hive Space (default) or an
/// explicit `room_id` / alias. `user` may be a fully-qualified id
/// (`@name:server`) or a bare localpart, which is qualified with the
/// homeserver's `server_name`. Returns the resolved room id the invite
/// targeted. Used by `hivectl matrix invite`.
/// Invite an arbitrary Matrix user to `room` (a room id or alias). `user`
/// may be a fully-qualified id (`@name:server`) or a bare localpart, which
/// is qualified with the homeserver's `server_name`. Returns the resolved
/// room id the invite targeted. Used by `hivectl matrix invite`.
///
/// # Errors
///
/// Returns an error if the sender token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user
/// already being a member / invited.
/// Returns an error if `room` is neither a room id nor an alias, or the
/// invite POST fails for a reason other than the user already being a
/// member / invited.
pub async fn invite_user(
client: &reqwest::Client,
sender_token: &str,
user: &str,
room_override: Option<&str>,
room: &str,
server_name: &str,
) -> Result<String> {
// Qualify a bare localpart to a full user id on the hive homeserver.
@ -509,39 +329,29 @@ pub async fn invite_user(
} else {
format!("@{user}:{server_name}")
};
// Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space.
let room_id = match room_override {
Some(r) if r.starts_with('#') => {
swarm_matrix_client::resolve_alias(client, matrix_base()?, sender_token, r).await?
}
Some(r) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \
let room_id = if room.starts_with('#') {
swarm_matrix_client::resolve_alias(client, matrix_base()?, sender_token, room).await?
} else if room.starts_with('!') {
room.to_owned()
} else {
anyhow::bail!(
"matrix: --room {room:?} is neither a room id nor an alias; \
prefix with '!' for a room id (!abc:server) or '#' for an \
alias (#name:server)"
),
None => std::fs::read_to_string(hive_space_room_id_path())
.map(|s| s.trim().to_owned())
.context(
"matrix: no --room given and no persisted hive space \
(run the hive-c0re matrix sweep first)",
)?,
)
};
invite_user_id(client, sender_token, &room_id, &user_id).await?;
Ok(room_id)
}
/// Make sure the hive's own `@hive-<hive>:` account exists, then provision
/// 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
/// hive-matrix container isn't running. Per-step failures are logged
/// but don't abort the sweep.
/// Make sure the hive's own `@hive-<hive>:` sender token is on disk.
/// 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 no homeserver is
/// configured.
///
/// Returns `true` when every step of the sweep succeeded, `false` when at
/// least one step failed — the caller feeds this into a
/// Returns `true` when the token is in place, `false` when it could not be
/// fetched — the caller feeds this into a
/// [`crate::stats::sweep_health::SweepHealth`] to raise a debounced
/// hive warning on persistent failure (this sweep re-runs every 30
/// minutes, so a one-off blip self-heals without ever warning).
@ -550,90 +360,11 @@ pub async fn ensure_all() -> bool {
tracing::debug!("matrix: hive-matrix container absent, skipping user sweep");
return true;
}
let mut ok = true;
// One HTTP client for the whole sweep.
let client = match swarm_matrix_client::client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep");
return false;
}
};
// The hive's `@hive-<hive>:` account first, because everything below provisions
// 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;
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("matrix: nixos-container list failed; skipping user sweep");
return false;
};
let mut agent_names: Vec<String> = Vec::new();
for c in &containers {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
agent_names.push(name.to_owned());
}
if !provision_space(&client, &agent_names).await {
ok = false;
}
ok
}
/// 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.
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites.
let sender_token = match read_sender_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
return false;
}
};
// server_name first — the agent invites need it (fully-qualified user ids).
let server_name = match discover_server_name(client).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space provisioning");
return false;
}
};
let room_id = match ensure_hive_space(client, &sender_token).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return false;
}
};
// This hive's own account first, then all agents.
let localpart = match hive_localpart() {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
return false;
}
};
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
{
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
ok = false;
}
for name in agent_names {
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false;
}
}
ok
true
}
#[cfg(test)]
@ -729,53 +460,4 @@ mod tests {
assert!(!message.contains("free-form-marker"), "{message}");
}
}
/// Serve a fake homeserver on loopback: `!space` is an `m.space` and
/// `!chat` a plain room, both named "shared"; `!decoy` is a plain room
/// named "other". Returns the base URL and the server task.
async fn fake_homeserver() -> (String, tokio::task::JoinHandle<()>) {
let app = axum::Router::new().fallback(|uri: axum::http::Uri| async move {
let path = uri.path();
let body = if path.ends_with("/joined_rooms") {
serde_json::json!({"joined_rooms": ["!decoy", "!space", "!chat"]})
} else if path.ends_with("/m.room.create/") {
if path.contains("space") {
serde_json::json!({"type": "m.space"})
} else {
serde_json::json!({})
}
} else if path.ends_with("/m.room.name/") {
let name = if path.contains("decoy") {
"other"
} else {
"shared"
};
serde_json::json!({"name": name})
} else {
serde_json::json!({})
};
axum::Json(body)
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let base = format!("http://{}", listener.local_addr().expect("local addr"));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
(base, server)
}
#[tokio::test]
async fn find_joined_room_matches_a_space_only_when_a_space_is_wanted() {
let (base, server) = fake_homeserver().await;
let client = reqwest::Client::new();
let want_space = find_joined_room(&client, &base, "tok", true, "shared").await;
let want_chat = find_joined_room(&client, &base, "tok", false, "shared").await;
let no_name = find_joined_room(&client, &base, "tok", true, "absent").await;
server.abort();
assert_eq!(want_space.as_deref(), Some("!space"));
assert_eq!(want_chat.as_deref(), Some("!chat"));
assert_eq!(no_name, None);
}
}

View file

@ -104,12 +104,6 @@ pub fn matrix_sender_token() -> PathBuf {
matrix_dir().join("access-token")
}
/// `matrix/space-room-id` — persisted hive Space room id.
#[must_use]
pub fn matrix_space_room_id() -> PathBuf {
matrix_dir().join("space-room-id")
}
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
#[must_use]
pub fn run_dir() -> PathBuf {
@ -258,11 +252,10 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
/// within the same filesystem is atomic.
pub fn relocate_legacy_state() {
let root = state_root();
let moves: [(&str, PathBuf); 5] = [
let moves: [(&str, PathBuf); 4] = [
("broker.sqlite", db_dir().join("broker.sqlite")),
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
("matrix-sender-token", matrix_sender_token()),
("matrix-space-room-id", matrix_space_room_id()),
("agent-sockets.json", agent_sockets_file()),
];
for (old_rel, new) in &moves {

View file

@ -203,9 +203,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
// federation peer-config block) and open the web surfaces
// (`hivectl open`).
HostRequest::Urls => HostResponse::urls(hive_urls()),
HostRequest::MatrixInvite { user, room } => {
handle_matrix_invite(user, room.as_deref()).await?
}
HostRequest::MatrixInvite { user, room } => handle_matrix_invite(user, room).await?,
HostRequest::ReconcileConfigStatus { agent, verbose } => {
crate::forge::reconcile_config_status(agent.as_str(), *verbose).await?
}
@ -500,7 +498,7 @@ async fn handle_push_snapshot(
Ok(HostResponse::success())
}
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
async fn handle_matrix_invite(user: &str, room: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;

View file

@ -223,14 +223,9 @@ pub enum HostRequest {
#[serde(default)]
scope: LifecycleScope,
},
/// Invite a matrix user to the hive Space (default) or a specific
/// `room`. Uses the daemon's admin token; idempotent
/// (already-member / already-invited is a no-op).
MatrixInvite {
user: String,
#[serde(default)]
room: Option<String>,
},
/// Invite a matrix user to `room` (id or alias). Uses the daemon's
/// admin token; idempotent (already-member / already-invited is a no-op).
MatrixInvite { user: String, room: String },
/// Report the divergence between agent `agent`'s local applied config
/// checkout and its forge `agent-configs/<agent>` `main`. The daemon
/// fetches forge `main` read-only and returns a human-readable report

View file

@ -27,7 +27,7 @@ dispatch:
container.
- **`forge.rs`** — reconciles an agent's config against its forge
repo.
- **`matrix.rs`** — invite a matrix user to the hive Space or a room.
- **`matrix.rs`** — invite a matrix user to a room.
- **`wg.rs`** — WireGuard mesh helpers.
- **`subvol.rs`** — btrfs state-subvolume ops.
- **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** —

View file

@ -38,9 +38,7 @@ pub enum Cmd {
},
/// matrix-tuwunel invites.
///
/// Manual invites into the hive Space and its rooms; c0re's periodic
/// matrix sweep provisions the Space and the agents' invites on
/// its own.
/// Manual invites into a room by id or alias.
Matrix {
#[command(subcommand)]
cmd: MatrixCmd,
@ -250,16 +248,14 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
#[derive(Subcommand)]
pub enum MatrixCmd {
/// Invite a matrix user to the hive Space, or a specific room with
/// `--room`. Idempotent.
/// Invite a matrix user to a room. Idempotent.
Invite {
/// User to invite: a full id (`@mara:server`) or a bare
/// localpart (qualified with the homeserver's `server_name`).
user: String,
/// Target room id (`!abc:server`) or alias (`#name:server`).
/// Omit to invite to the hive Space.
#[arg(long)]
room: Option<String>,
room: String,
},
}

View file

@ -12,7 +12,7 @@ use crate::cli::MatrixCmd;
/// dispatch match so the top-level router stays small.
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
match cmd {
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, &room).await,
}
}
@ -36,12 +36,12 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu
Ok(())
}
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
async fn matrix_invite(socket: &Path, user: &str, room: &str) -> Result<()> {
matrix_request(
socket,
hive_host_sock::HostRequest::MatrixInvite {
user: user.to_owned(),
room: room.map(str::to_owned),
room: room.to_owned(),
},
)
.await

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 and the invites)
# rides on being the Space's creator, not on admin.
# credential; what it does (the invites) rides on its
# power in the room, not on admin.
#
# The registration load creates the sender inside
# `Services::start()`, and `admin_execute` runs after that and

View file

@ -15,9 +15,8 @@ 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 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
`hive-c0re` invites users into rooms with the room calls (`room_state`,
`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
sets its own timeout; `client()` is the 10 second default.

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 provisioning need (resolve an alias, list
//! `hive-c0re`'s invites 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.