also stop provisioning the hive space and make hivectl matrix invite take a room
This commit is contained in:
parent
c0dc4b07f4
commit
fd01848d4d
20 changed files with 83 additions and 437 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`** —
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue