hive-c0re: stop minting agents' matrix accounts
The swarm mints each agent's `main` account now, so the hive's own mint goes: `ensure_user_for`, `finish_user_provisioning`, `sync_agent`, `sync_agent_standalone`, `token_path`, `legacy_password_path`, `auto_reset_password` and `token_file_present`, and the calls from the startup sweep and the rebuild bookkeeping. Both mints pinned the device `hyperhive-<agent>`, so leaving this one would have each re-login kill the other's token. `hivectl matrix create-user` refuses an agent's name and says where its account comes from. Everything that still uses the hive's appservice token stays: the hive's own account, the Space and chat room, and operator accounts.
This commit is contained in:
parent
ab153bda2f
commit
89a5dd752c
6 changed files with 61 additions and 351 deletions
|
|
@ -142,7 +142,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the matrix account + access token for `<name>`
|
||||
* `create-user` — Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout
|
||||
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
||||
* `promote-user` — Promote a matrix user to homeserver admin
|
||||
* `reset-password` — Reset a matrix user's password via the admin API
|
||||
|
|
@ -152,15 +152,15 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
|
||||
## `hivectl matrix create-user`
|
||||
|
||||
Create or refresh the matrix account + access token for `<name>`.
|
||||
Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout.
|
||||
|
||||
For an existing agent, persists the token to its state dir; for a human/other account, prints the access token to stdout. Set a password to enable matrix web-client login (otherwise it uses a random throwaway).
|
||||
Refuses an agent's name: its account comes from the swarm (`swarm-controller` creates it and stores its token where the agent reads it). Set a password to enable matrix web-client login (otherwise it uses a random throwaway).
|
||||
|
||||
**Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc
|
||||
* `<NAME>` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc
|
||||
|
||||
###### **Options:**
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow
|
|||
running (`services.hyperhive.deploy.matrix.enable = true`).
|
||||
|
||||
```bash
|
||||
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
|
||||
hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout
|
||||
hivectl matrix create-user mara --password hunter2 # set a client-login password
|
||||
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
||||
|
|
@ -60,9 +59,9 @@ 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
|
||||
```
|
||||
|
||||
- `create-user`: for agents, persists the `access_token` to
|
||||
`<state>/matrix-token`. Skips registration when the file already
|
||||
exists — delete it first to force re-registration.
|
||||
- `create-user`: for people and other non-agent accounts. It refuses
|
||||
an agent's name: `swarm-controller` creates an agent's account and
|
||||
stores its token where the agent's daemon reads it.
|
||||
- `sync-admin`: ensures this hive's appservice sender account
|
||||
(`@hive-<hive>:<server_name>`, one per hive) exists
|
||||
(the account `hive-c0re` provisions rooms with). Token persisted to the
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ async fn run_swap(coord: &Arc<Coordinator>, name: &str, id: NodeId) -> Result<()
|
|||
|
||||
/// The post-`Swap` bookkeeping tail, split into its own node for dashboard
|
||||
/// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here
|
||||
/// means the profile swap succeeded. Store/forge/matrix work only — no nix
|
||||
/// means the profile swap succeeded. Store/forge work only — no nix
|
||||
/// build (build-slot-exempt); the agent lease taken at `Swap` is still held
|
||||
/// (the whole chain up to `Reconcile` is one agent's subgraph).
|
||||
async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
||||
|
|
@ -477,11 +477,11 @@ async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result
|
|||
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's
|
||||
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
|
||||
// add a contradictory !ok would double-report the same rebuild.
|
||||
// Full forge + matrix sync on every successful rebuild so the rebuild
|
||||
// path is equivalent to the startup sweep: tokens, config-repo mirror,
|
||||
// meta access all recover without a hive-c0re restart.
|
||||
// Full forge sync on every successful rebuild so the rebuild path is
|
||||
// equivalent to the startup sweep: tokens, config-repo mirror, meta
|
||||
// access all recover without a hive-c0re restart. (No matrix step: the
|
||||
// swarm mints an agent's matrix account, not this hive.)
|
||||
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
|
||||
crate::matrix::sync_agent_standalone(name).await;
|
||||
// Wake the agent on its next turn so claude sees a "you were rebuilt"
|
||||
// hint; rescan so dashboards drop the "needs update" chip; lock bump →
|
||||
// meta-inputs re-render.
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ use std::path::PathBuf;
|
|||
use anyhow::{Context, Result};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Client-server API base this daemon provisions against, from
|
||||
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
|
||||
/// `services.hyperhive.swarm.matrix.apiUrl`).
|
||||
|
|
@ -126,45 +124,15 @@ pub fn sender_token_path() -> PathBuf {
|
|||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
/// `/state/matrix-token` from inside the container).
|
||||
fn token_path(name: &hive_types::Ident) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-token")
|
||||
}
|
||||
|
||||
/// Whether an agent's token file is present: a non-empty regular file.
|
||||
/// Decided from metadata alone, because hive-priv writes the file 0600
|
||||
/// and owned by the agent, so `hive-core` cannot read it but can stat it
|
||||
/// through the 0755 state dir. A check that reads the file always
|
||||
/// fails here and makes every sweep re-mint the token.
|
||||
fn token_file_present(path: &std::path::Path) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > 0)
|
||||
}
|
||||
|
||||
/// Password file for the agent's matrix account. Stored OUTSIDE the
|
||||
/// purgeable `agent_state_root` tree so it survives `destroy --purge`
|
||||
/// and allows re-login recovery when the same agent name is re-spawned.
|
||||
/// Password file for a matrix account this hive holds a password for: its own
|
||||
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
|
||||
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
|
||||
///
|
||||
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
|
||||
///
|
||||
/// The token file lives inside the agent's bind-mounted state dir (under
|
||||
/// `agent_notes_dir`) so the agent container can read it; the password
|
||||
/// file is host-side only (agents never log in by password — they use
|
||||
/// the access token exclusively) and belongs with other hive-c0re
|
||||
/// credential state, not inside the purgeable per-agent tree.
|
||||
fn password_path(name: &str) -> PathBuf {
|
||||
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
|
||||
}
|
||||
|
||||
/// Legacy password path (inside the old purgeable `agent_notes_dir`).
|
||||
/// Used only during the one-time migration in [`ensure_user_for`] to
|
||||
/// move credentials from old deployments to the new location. Safe to
|
||||
/// call after `destroy --purge` — the path will simply not exist and
|
||||
/// the migration is a no-op.
|
||||
fn legacy_password_path(name: &hive_types::Ident) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-password")
|
||||
}
|
||||
|
||||
/// Host path where the hive Matrix Space room ID is persisted.
|
||||
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
||||
#[must_use]
|
||||
|
|
@ -319,8 +287,8 @@ async fn register_user(
|
|||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": password,
|
||||
// device_id stays stable across re-runs of ensure_user_for so
|
||||
// a re-mint doesn't strand orphan devices in tuwunel.
|
||||
// device_id stays stable across re-runs so a re-mint doesn't
|
||||
// strand orphan devices in tuwunel.
|
||||
"device_id": format!("hyperhive-{agent}"),
|
||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||
"inhibit_login": false,
|
||||
|
|
@ -416,32 +384,6 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
|||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
/// Auto-recovery helper: reset a user's matrix password through the admin
|
||||
/// room when the locally stored password is missing. Returns the new
|
||||
/// password (already persisted to [`password_path`]) on success.
|
||||
///
|
||||
/// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
|
||||
/// `!admin` command and tuwunel only treats a message as a command when
|
||||
/// its sender is an admin in that room. So this recovery path fails until
|
||||
/// the two admin operations are rehomed at swarm level; the ordinary
|
||||
/// route (the stored password, or an appservice login) is unaffected.
|
||||
///
|
||||
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
|
||||
/// but the password file is absent — covers the case where agent state dirs
|
||||
/// were wiped but the homeserver still has the accounts.
|
||||
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
|
||||
let sender_token = read_sender_token()
|
||||
.context("matrix: the matrix sender token is unavailable for auto-recovery")?;
|
||||
let server_name = discover_server_name(client)
|
||||
.await
|
||||
.context("matrix: discover_server_name for auto-recovery")?;
|
||||
let effective_password = reset_user_password(client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
|
||||
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
|
||||
Ok(effective_password)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin-room fallback for password reset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -711,162 +653,13 @@ async fn admin_room_reset_password(
|
|||
})
|
||||
}
|
||||
|
||||
/// Ensure `name` has a matrix user + token file on the local
|
||||
/// homeserver. Skips provisioning entirely if the token file already
|
||||
/// exists (treating a present token as proof the account is good).
|
||||
/// To force re-provisioning, delete the token file.
|
||||
///
|
||||
/// When registration fails with `M_USER_IN_USE` (account exists in the
|
||||
/// homeserver but the token file was deleted) this falls back to
|
||||
/// `m.login.password` using the persisted `matrix-password` file. If
|
||||
/// that file is also missing, recovery requires manual intervention:
|
||||
/// `hivectl matrix create-user <name> --password <pw>`.
|
||||
///
|
||||
/// `client` is shared across the sweep so we build one reqwest
|
||||
/// connection pool for all agents rather than one per call.
|
||||
pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let agent = hive_types::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
if token_file_present(&token_path(&agent)) {
|
||||
tracing::debug!(%name, "matrix: token already present");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// One-time migration: move the password from the old location inside
|
||||
// agent_notes_dir (purgeable) to the new location outside it.
|
||||
let new_pw_path = password_path(name);
|
||||
let old_pw_path = legacy_password_path(&agent);
|
||||
if !new_pw_path.exists() && old_pw_path.exists() {
|
||||
if let Some(parent) = new_pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&old_pw_path, &new_pw_path) {
|
||||
// Rename across filesystems or read-only src — copy + delete.
|
||||
if let Ok(content) = std::fs::read(&old_pw_path) {
|
||||
if std::fs::write(&new_pw_path, &content).is_ok() {
|
||||
let _ = std::fs::remove_file(&old_pw_path);
|
||||
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(%name, rename_error = ?e, "matrix: password migration failed — could not read old path (old path stays)");
|
||||
}
|
||||
} else {
|
||||
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
|
||||
}
|
||||
}
|
||||
|
||||
let password = random_password()?;
|
||||
let access_token = match register_user(client, name, as_token, &password).await {
|
||||
Ok(token) => {
|
||||
// Successful registration — persist the password so we can
|
||||
// fall back to login if the token file is deleted later.
|
||||
let pw_path = password_path(name);
|
||||
if let Some(parent) = pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
|
||||
tracing::warn!(%name, error = ?e, "matrix: failed to persist password (token still saved)");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
token
|
||||
}
|
||||
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
||||
// Account already exists and its token file was lost. The
|
||||
// appservice can mint a session for any account in its
|
||||
// namespace, so this needs no password and no admin rights —
|
||||
// try it before the password paths below, which exist for
|
||||
// accounts created before the appservice did (or named
|
||||
// outside its namespace) and stay as the fallback.
|
||||
tracing::info!(%name, "matrix: user already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, name).await {
|
||||
Ok(token) => return finish_user_provisioning(name, &token).await,
|
||||
Err(e) => tracing::warn!(
|
||||
%name,
|
||||
error = ?e,
|
||||
"matrix: appservice login failed; falling back to the stored password"
|
||||
),
|
||||
}
|
||||
let pw_path = password_path(name);
|
||||
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
pw
|
||||
} else {
|
||||
// Password file missing — attempt auto-recovery through the
|
||||
// admin room.
|
||||
// This covers the case where agent state dirs were wiped but the
|
||||
// homeserver still has the accounts. Requires the hive's sender
|
||||
// token at /var/lib/hyperhive/matrix/access-token, and an admin
|
||||
// sender, which `@hive-<hive>:` is not.
|
||||
tracing::info!(
|
||||
%name,
|
||||
"matrix: stored password missing, attempting admin-room auto-recovery"
|
||||
);
|
||||
match auto_reset_password(client, name).await {
|
||||
Ok(new_pw) => new_pw,
|
||||
Err(e) => {
|
||||
anyhow::bail!(
|
||||
"matrix: user {name} already exists but password is missing \
|
||||
and admin auto-recovery failed ({e:#}) — run:\n\
|
||||
hivectl matrix reset-password {name}\n\
|
||||
hivectl matrix create-user {name}"
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
login_user(client, name, &stored).await.with_context(|| {
|
||||
format!(
|
||||
"matrix: login fallback for {name} failed — if homeserver was wiped, delete \
|
||||
the matrix-password file and retry"
|
||||
)
|
||||
})?
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
|
||||
finish_user_provisioning(name, &access_token).await
|
||||
}
|
||||
|
||||
/// Persist a freshly-obtained agent access token and kick the agent's
|
||||
/// matrix daemon. Shared by every way [`ensure_user_for`] can end up
|
||||
/// holding a token — creation, appservice login, password login — so a
|
||||
/// new recovery path cannot forget half of it.
|
||||
async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> {
|
||||
// Write the token via hive-priv (root helper): hive-c0re runs as the
|
||||
// unprivileged `hive-core` user and cannot write to agent-owned state
|
||||
// directories directly. hive-priv writes the file 0600 and chowns it
|
||||
// to the agent user so it is readable from inside the container.
|
||||
crate::priv_client::write_agent_matrix_token(name, access_token, None, None)
|
||||
.await
|
||||
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
||||
tracing::info!(%name, "matrix: provisioned access token");
|
||||
|
||||
// Kick the daemon so it picks up the new token without waiting for a
|
||||
// full container restart — see docs/integrations/matrix.md::Provisioning flow.
|
||||
if let Err(e) = crate::priv_client::restart_matrix_daemon(name).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: could not restart hive-matrix-daemon (token written; daemon will reload on next container start)");
|
||||
} else {
|
||||
tracing::info!(%name, "matrix: restarted hive-matrix-daemon to pick up new token");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a matrix account for `name` with the supplied `password`
|
||||
/// and return the freshly-minted access token. Unlike [`ensure_user_for`],
|
||||
/// the token is **not** persisted to disk — the caller is responsible
|
||||
/// for storing it. Used by `hivectl matrix create-user` for human
|
||||
/// (non-agent) accounts so we don't create stray
|
||||
/// `/var/lib/hyperhive/agents/<name>/` directories for users that
|
||||
/// aren't agents. For operator accounts the caller passes a real
|
||||
/// and return the freshly-minted access token. The token is **not**
|
||||
/// persisted to disk — the caller is responsible for storing it. Used by
|
||||
/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real
|
||||
/// password so the operator can `m.login.password` into matrix web
|
||||
/// clients afterwards; for headless agent re-provisioning the caller
|
||||
/// can pass [`random_password`] to keep the existing throwaway
|
||||
/// behaviour.
|
||||
/// clients afterwards; without one the caller passes
|
||||
/// [`random_password`].
|
||||
///
|
||||
/// **Not idempotent**: the matrix `/register` endpoint returns
|
||||
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
|
||||
|
|
@ -882,44 +675,6 @@ pub async fn provision_user_token(
|
|||
register_user(client, name, as_token, password).await
|
||||
}
|
||||
|
||||
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
|
||||
/// All operations are idempotent; failures are logged as warnings but
|
||||
/// don't abort the caller.
|
||||
pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) {
|
||||
if let Err(e) = ensure_user_for(client, name, as_token).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone per-agent sync that handles its own setup: checks if
|
||||
/// hive-matrix is present, reads the appservice token, and builds
|
||||
/// an HTTP client before delegating to [`sync_agent`]. Mirrors the
|
||||
/// setup in [`ensure_all`] so the rebuild path and the startup sweep
|
||||
/// stay equivalent. No-op when the matrix container is absent.
|
||||
pub async fn sync_agent_standalone(name: &str) {
|
||||
if !is_present() {
|
||||
return;
|
||||
}
|
||||
let as_token = match read_appservice_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
sync_agent(&client, name, &as_token).await;
|
||||
}
|
||||
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
///
|
||||
|
|
@ -1179,8 +934,7 @@ pub async fn promote_user_to_admin(
|
|||
///
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
|
||||
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
|
||||
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
|
||||
/// on the next provisioning sweep.
|
||||
/// it to the non-purgeable creds path.
|
||||
///
|
||||
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
||||
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
||||
|
|
@ -1874,10 +1628,11 @@ async fn resolve_room_alias(
|
|||
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
|
||||
}
|
||||
|
||||
/// Sweep every existing container (manager + sub-agents) and ensure
|
||||
/// each has a matrix user + token on the local homeserver. Called at
|
||||
/// hive-c0re startup, alongside `forge::ensure_all`, and then
|
||||
/// periodically (see the caller in `main.rs`). No-op when the
|
||||
/// 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.
|
||||
/// Agents' own accounts are not created here: `swarm-controller` mints them
|
||||
/// with the swarm's appservice token. Called at hive-c0re startup, alongside
|
||||
/// `forge::ensure_all`, and then periodically (see the caller in `main.rs`). No-op when the
|
||||
/// hive-matrix container isn't running. Per-step failures are logged
|
||||
/// but don't abort the sweep.
|
||||
///
|
||||
|
|
@ -1893,9 +1648,7 @@ pub async fn ensure_all() -> bool {
|
|||
}
|
||||
let mut ok = true;
|
||||
// Loud and non-destructive: with no appservice token this sweep can
|
||||
// create nothing, so it does nothing. Agents that already hold a
|
||||
// token keep using it — their accounts and sessions are untouched by
|
||||
// anything in here.
|
||||
// create nothing, so it does nothing.
|
||||
let as_token = match read_appservice_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
|
|
@ -1903,8 +1656,7 @@ pub async fn ensure_all() -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// One HTTP client for the whole sweep — connection pool is
|
||||
// reused across agents.
|
||||
// One HTTP client for the whole sweep.
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.build()
|
||||
|
|
@ -1932,7 +1684,6 @@ pub async fn ensure_all() -> bool {
|
|||
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
sync_agent(&client, name, &as_token).await;
|
||||
agent_names.push(name.to_owned());
|
||||
}
|
||||
|
||||
|
|
@ -2101,30 +1852,4 @@ mod tests {
|
|||
let err = extract_access_token(&body).unwrap_err();
|
||||
assert!(err.to_string().contains("missing access_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_file_present_only_for_a_non_empty_regular_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let token = dir.path().join("matrix-token");
|
||||
assert!(!token_file_present(&token), "missing file");
|
||||
std::fs::write(&token, "").unwrap();
|
||||
assert!(!token_file_present(&token), "empty file");
|
||||
std::fs::write(&token, "tok\n").unwrap();
|
||||
assert!(token_file_present(&token), "non-empty file");
|
||||
assert!(!token_file_present(dir.path()), "directory");
|
||||
}
|
||||
|
||||
/// The sweep runs as `hive-core`, which cannot read the agent-owned
|
||||
/// 0600 token file. `chmod 000` does not stop root, so this test uses
|
||||
/// content that `read_to_string` rejects instead: the predicate must
|
||||
/// still report the file as present, which holds only if it never
|
||||
/// reads the content.
|
||||
#[test]
|
||||
fn token_file_present_does_not_read_the_content() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let token = dir.path().join("matrix-token");
|
||||
std::fs::write(&token, [0xff, 0xfe]).unwrap();
|
||||
assert!(std::fs::read_to_string(&token).is_err());
|
||||
assert!(token_file_present(&token));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,51 +539,37 @@ async fn handle_matrix_create_user(
|
|||
password: Option<&str>,
|
||||
) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
if agent_exists(name)? {
|
||||
// The swarm mints an agent's account and stores its token where the
|
||||
// agent reads it; a second minter here would replace that token on the
|
||||
// same device.
|
||||
anyhow::bail!(
|
||||
"matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \
|
||||
the swarm: swarm-controller creates it and re-checks it every five minutes"
|
||||
);
|
||||
}
|
||||
let as_token =
|
||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||
let client = matrix_http_client()?;
|
||||
let mut out = Vec::new();
|
||||
if agent_exists(name)? {
|
||||
if password.is_some() {
|
||||
// Agents auth by access_token, never by password — the
|
||||
// boot-sweep provisioning path doesn't accept one. Refuse
|
||||
// rather than silently dropping it.
|
||||
anyhow::bail!(
|
||||
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||
);
|
||||
}
|
||||
crate::matrix::ensure_user_for(&client, name.as_str(), &as_token)
|
||||
let effective_password = match password {
|
||||
Some(p) => p.to_owned(),
|
||||
None => crate::matrix::random_password().context("generate random matrix password")?,
|
||||
};
|
||||
let token =
|
||||
crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||
out.push(format!("matrix: provisioned agent user '{name}'"));
|
||||
out.push(format!("token persisted at: {}", path.display()));
|
||||
out.push(format!(
|
||||
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into a matrix web client".to_owned());
|
||||
} else {
|
||||
let effective_password = match password {
|
||||
Some(p) => p.to_owned(),
|
||||
None => crate::matrix::random_password().context("generate random matrix password")?,
|
||||
};
|
||||
let token = crate::matrix::provision_user_token(
|
||||
&client,
|
||||
name.as_str(),
|
||||
&as_token,
|
||||
&effective_password,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
out.push(format!(
|
||||
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
if password.is_some() {
|
||||
out.push(
|
||||
"password: set as supplied — use it to log into a matrix web client".to_owned(),
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,15 +286,15 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MatrixCmd {
|
||||
/// Create or refresh the matrix account + access token for `<name>`.
|
||||
/// Create a matrix account for a person or other non-agent `<name>` and
|
||||
/// print its access token to stdout.
|
||||
///
|
||||
/// For an existing agent, persists the token to its state dir; for a
|
||||
/// human/other account, prints the access token to stdout. Set a
|
||||
/// password to enable matrix web-client login (otherwise it uses a
|
||||
/// random throwaway).
|
||||
/// Refuses an agent's name: its account comes from the swarm
|
||||
/// (`swarm-controller` creates it and stores its token where the agent
|
||||
/// reads it). Set a password to enable matrix web-client login
|
||||
/// (otherwise it uses a random throwaway).
|
||||
CreateUser {
|
||||
/// Matrix localpart. For agents: the container/agent name.
|
||||
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
|
||||
/// Matrix localpart of a non-agent account — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
|
|
|
|||
Loading…
Reference in a new issue