Compare commits
39 changed files with 572 additions and 2164 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -1989,7 +1989,6 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -4914,7 +4913,6 @@ dependencies = [
|
|||
"sha2 0.11.0",
|
||||
"strum",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-matrix-client",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"time",
|
||||
|
|
@ -4937,23 +4935,14 @@ dependencies = [
|
|||
"swarm-queue-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-matrix-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-matrix-ctl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"swarm-matrix-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ members = [
|
|||
"swarm-authelia-bridge",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-controller",
|
||||
"swarm-matrix-client",
|
||||
"swarm-matrix-ctl",
|
||||
"swarm-nats-auth",
|
||||
"swarm-queue-client",
|
||||
|
|
@ -102,7 +101,6 @@ hive-priv-sock = { path = "hive-priv-sock" }
|
|||
hive-sock-client = { path = "hive-sock-client" }
|
||||
hive-types = { path = "hive-types" }
|
||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||
swarm-matrix-client = { path = "swarm-matrix-client" }
|
||||
swarm-queue-client = { path = "swarm-queue-client" }
|
||||
swarm-secret-client = { path = "swarm-secret-client" }
|
||||
thiserror = "2"
|
||||
|
|
|
|||
|
|
@ -569,9 +569,8 @@ so on a real hive (where hive-c0re renders that URL per agent) every
|
|||
agent has one; a `null` URL with no operator-declared account is the
|
||||
"this agent has no matrix" state.
|
||||
|
||||
**First-boot ordering**: a token can arrive after the container comes
|
||||
up — a file the hive delivers, or a token the swarm mints into the store.
|
||||
Without the path-trigger sibling
|
||||
**First-boot ordering**: hive-c0re provisions the matrix token AFTER
|
||||
agent containers come up. Without the path-trigger sibling
|
||||
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
||||
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
|
||||
a secondary multi-account token like `matrix-token-ccc`), the daemon
|
||||
|
|
@ -579,9 +578,7 @@ would exit 0 quietly the first time it ran and the MCP would have no
|
|||
daemon until the next restart. The `.path` unit makes the appearance
|
||||
of the token re-fire the service so the daemon comes alive in the
|
||||
same boot cycle as
|
||||
provisioning. A token in the store changes no file, so a store-backed agent
|
||||
also gets a timer that restarts the daemon five minutes after it last
|
||||
exited. The same token watcher also drives avatar setting: on a
|
||||
provisioning. The same token watcher also drives avatar setting: on a
|
||||
restart the daemon re-runs each account's bring-up, which sets the
|
||||
avatar (see below).
|
||||
|
||||
|
|
|
|||
|
|
@ -44,8 +44,7 @@ hivectl agent ruth rebuild
|
|||
|
||||
You don't need to do anything else. The controller's next pass creates
|
||||
ruth's forge user and mints her token, and her container fetches it within
|
||||
about ten minutes. The same identity puts her in the matrix pass too (step
|
||||
6). `swarmctl agent mint-forge-token ruth` skips the wait for
|
||||
about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
|
||||
the pass. A hive without a swarm secret store has no path to a forge token
|
||||
for ruth at all.
|
||||
|
||||
|
|
@ -238,6 +237,10 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
|||
# Ensure the appservice's sender account exists first
|
||||
hivectl matrix sync-admin
|
||||
|
||||
# Provision ruth's own matrix account — same bootstrap-bypass reasoning
|
||||
# as forge above, still a required manual step.
|
||||
hivectl matrix create-user ruth
|
||||
|
||||
# Invite the operator to the hive Space (and optionally to rooms)
|
||||
hivectl matrix invite mara
|
||||
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
||||
|
|
@ -246,12 +249,6 @@ hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
|||
hivectl matrix promote-user mara
|
||||
```
|
||||
|
||||
ruth's own matrix account comes from the swarm, like every agent's:
|
||||
`swarm-controller` creates it within five minutes of her holding a store
|
||||
identity (step 1), and her matrix daemon reads its token from the store.
|
||||
Without that identity she has no matrix account, and this hive no longer
|
||||
creates one.
|
||||
|
||||
Swarm SSO creates the human operator's own matrix account instead of
|
||||
a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).
|
||||
|
||||
|
|
|
|||
|
|
@ -129,52 +129,31 @@ a token.
|
|||
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
||||
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||
the credential id is what makes this work.
|
||||
5. **hive-c0re** reads the appservice token and creates its own
|
||||
`@hive-<hive>:` account, and the accounts an operator asks for with
|
||||
`hivectl matrix create-user`. It never mints the token itself: the value
|
||||
has to be the one the rendered registration names, and only the nix
|
||||
side writes that.
|
||||
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
|
||||
one with the swarm's own appservice token (next section), stores its
|
||||
token at `swarm/agents/<agent>/matrix/main`, and the agent's
|
||||
`hive-matrix-daemon` reads it from there as the agent itself.
|
||||
|
||||
### The swarm's appservice, and its admin sender
|
||||
|
||||
A second registration sits beside the hive's: id `swarm`, sender `@swarm`,
|
||||
the same non-exclusive namespace. It's the swarm's identity on the
|
||||
homeserver, and `swarm-controller` creates every agent's account with it.
|
||||
tuwunel loads every `.yaml` in `appservice_dir` and refuses only a
|
||||
duplicate `id` or `as_token`, so the two overlapping namespaces coexist.
|
||||
|
||||
`swarm-matrix-ctl appservice render` mints its tokens inside the
|
||||
`hive-matrix` container, before tuwunel starts, and renders the
|
||||
registration tuwunel loads as a second credential. `appservice publish`
|
||||
then writes its `as_token` to
|
||||
`swarm/controller/swarm-controller/matrix/appservice-token`. No hive's
|
||||
policy reaches that path: only matrix-ctl (which writes it) and
|
||||
`swarm-controller` (which reads it) hold a grant to it.
|
||||
|
||||
`@swarm` is the **one** account the homeserver promotes to admin at boot
|
||||
(`admin_execute`). Only those two principals read its token, which is
|
||||
the difference from the hive sender below: every hive reads its own
|
||||
sender's token. `swarm` is a reserved name, so nothing can create an agent
|
||||
as this account.
|
||||
|
||||
`swarm-controller` mints each agent's account with the device id
|
||||
`hyperhive-<agent>`, and a login on that device replaces its token. The controller therefore reads
|
||||
the stored token back with `whoami` every five minutes and re-mints only
|
||||
when it's missing, unknown to the homeserver, or someone else's. An
|
||||
agent's daemon picks a re-minted token up on its next start, and a timer
|
||||
restarts it while it's down.
|
||||
5. **hive-c0re** reads the appservice token and creates each account with
|
||||
one `POST /register` typed `m.login.application_service`, persisting
|
||||
the returned `access_token` to `<agent-state>/matrix-token`. It never
|
||||
mints the token itself: the value has to be the one the rendered
|
||||
registration names, and only the nix side writes that.
|
||||
6. **An account that exists but has lost its token file** is re-tokened
|
||||
by an appservice `POST /login` — no password and no admin rights
|
||||
involved. A stored-password login and an admin-room password reset
|
||||
remain behind that, for accounts created before the appservice existed
|
||||
or named outside its namespace.
|
||||
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
||||
immediately after writing the token so the daemon picks up the
|
||||
new credential without waiting for a full container restart. If
|
||||
the restart fails (for example daemon not yet running on first boot)
|
||||
hive-c0re logs the error as a warning and the `.path`-trigger sibling
|
||||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||
brings the daemon up on the same boot cycle anyway.
|
||||
|
||||
### The appservice's sender account, and why it isn't an admin
|
||||
|
||||
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
||||
which the homeserver creates itself when it loads the registration — on a
|
||||
zero-user database, inside startup, before the HTTP listener accepts
|
||||
anything. It's an **ordinary account**: nothing promotes it; the
|
||||
homeserver's `admin_execute` promotes only `@swarm`.
|
||||
anything. It's an **ordinary account**: nothing promotes it, and the
|
||||
homeserver runs no `admin_execute` for it.
|
||||
|
||||
**One account per hive.** The localpart carries the hive's name, so a swarm
|
||||
whose hives share a homeserver gives each of them its own identity: the
|
||||
|
|
@ -206,8 +185,10 @@ when its sender is already an admin. They're swarm-level operations,
|
|||
rehomed to the swarm tier rather than granted here; from the hive,
|
||||
`@hive-<hive>:` has no admin sender to make that call with, so both get the
|
||||
admin room's refusal rather than an over-privileged credential that
|
||||
every other call site would also carry. The swarm's own sender is the
|
||||
admin they need; moving them there is separate work.
|
||||
every other call site would also carry. The one hive-side path that
|
||||
depends on them is the automatic password recovery for an agent that
|
||||
has lost its stored password — the admin-sender limitation doesn't
|
||||
touch the ordinary appservice re-login above.
|
||||
|
||||
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
|
||||
|
||||
|
|
@ -258,6 +239,9 @@ restarts, so the first boot after the switch already has both halves.
|
|||
device that minted it; removing the registration token touches no
|
||||
device, no account and no session. `login_with_password` stays on, so
|
||||
the password fallback is still there too.
|
||||
- **The per-agent sweep honours existing token files.** It skips any
|
||||
agent that already has a `matrix-token`, so it re-registers no account
|
||||
and displaces no session.
|
||||
- **The sender account may already be an admin** on such a hive (it won the
|
||||
first-user grant when the hive was new). Nothing here demotes it; the
|
||||
homeserver no longer promotes it, so a hive built fresh has an
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ strategy for every credential, including the mTLS leaf.
|
|||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | none: the container keeps its copy and republishes it when the store's differs |
|
||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
|
||||
|
|
@ -70,19 +68,6 @@ strategy for every credential, including the mTLS leaf.
|
|||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
**Two rows share the `matrix/` prefix and have different minters, on
|
||||
purpose.** An agent's `main` account is on the swarm's own homeserver, and
|
||||
`swarm-controller` creates it with the swarm's appservice token. Every other
|
||||
account under that prefix is somewhere else entirely, and an operator hands
|
||||
the controller a credential for it. The operator-facing route refuses the
|
||||
name `main` for exactly this reason: two writers, one name, and that refusal
|
||||
is what keeps them apart.
|
||||
|
||||
**The swarm's appservice token sits under `controller/`, the one kind no
|
||||
hive's policy reads.** Its sender is the homeserver's admin. Under
|
||||
`agents/`, `hives/` or `services/` every hive could read it. matrix-ctl may
|
||||
write and read that one leaf; the controller may only read it.
|
||||
|
||||
**An agent's mTLS leaf is in the store; a hive's isn't, and the difference
|
||||
isn't an inconsistency.** The rule the exception protects is that nothing
|
||||
can fetch from the store the credential it would need in order to fetch. A
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout
|
||||
* `create-user` — Create or refresh the matrix account + access token for `<name>`
|
||||
* `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 a matrix account for a person or other non-agent `<name>` and print its access token to stdout.
|
||||
Create or refresh the matrix account + access token for `<name>`.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
**Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc
|
||||
* `<NAME>` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc
|
||||
|
||||
###### **Options:**
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ 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
|
||||
|
|
@ -59,9 +60,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 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.
|
||||
- `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.
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -88,10 +88,10 @@ cache), and an optional `homeserver` (defaults to
|
|||
always named `main` and is always the primary; the matrix module
|
||||
declares it for you as an ordinary entry of this map, from
|
||||
`services.hyperhive.agent.matrix.url` + agent state, so what you add
|
||||
here are the *further* accounts. Its token comes from the swarm secret
|
||||
store (`swarm-controller` mints it); its `tokenFile` stays pinned to
|
||||
`<state>/matrix-token` as the fallback, and the dashboard's link-account
|
||||
route refuses to create an account by that name.
|
||||
here are the *further* accounts. hive-c0re pins its `tokenFile` to
|
||||
`<state>/matrix-token` and provisions it there, and the
|
||||
dashboard's link-account route refuses to create an account by that
|
||||
name.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
`main` is present exactly when that URL is non-null, which is the whole
|
||||
|
|
@ -122,10 +122,9 @@ directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort`
|
|||
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
|
||||
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
|
||||
to the stable URL every turn instead of respawning a stdio child.
|
||||
Silently exits when the primary account has no token yet; the
|
||||
`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file
|
||||
appears, and, on an agent with a store, a timer restarts it while it's
|
||||
down so it re-reads a token the swarm minted into the store.
|
||||
Silently exits when `<state>/matrix-token` is absent (account not yet
|
||||
provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts
|
||||
it the moment hive-c0re provisions the token.
|
||||
|
||||
Incoming room events wake the agent via `AgentRequest::Wake` with
|
||||
`from: "matrix"`. The wake body format depends on the unread state:
|
||||
|
|
|
|||
|
|
@ -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 work only — no nix
|
||||
/// means the profile swap succeeded. Store/forge/matrix 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 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.)
|
||||
// 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.
|
||||
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,6 +20,8 @@ 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`).
|
||||
|
|
@ -124,15 +126,45 @@ pub fn sender_token_path() -> PathBuf {
|
|||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// 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.
|
||||
///
|
||||
/// 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]
|
||||
|
|
@ -287,8 +319,8 @@ async fn register_user(
|
|||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": password,
|
||||
// device_id stays stable across re-runs so a re-mint doesn't
|
||||
// strand orphan devices in tuwunel.
|
||||
// device_id stays stable across re-runs of ensure_user_for 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,
|
||||
|
|
@ -384,6 +416,32 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -653,13 +711,162 @@ 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. 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
|
||||
/// 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
|
||||
/// password so the operator can `m.login.password` into matrix web
|
||||
/// clients afterwards; without one the caller passes
|
||||
/// [`random_password`].
|
||||
/// clients afterwards; for headless agent re-provisioning the caller
|
||||
/// can pass [`random_password`] to keep the existing throwaway
|
||||
/// behaviour.
|
||||
///
|
||||
/// **Not idempotent**: the matrix `/register` endpoint returns
|
||||
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
|
||||
|
|
@ -675,6 +882,44 @@ 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()`].
|
||||
///
|
||||
|
|
@ -934,7 +1179,8 @@ 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.
|
||||
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
|
||||
/// on the next provisioning sweep.
|
||||
///
|
||||
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
||||
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
||||
|
|
@ -1628,11 +1874,10 @@ async fn resolve_room_alias(
|
|||
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// hive-matrix container isn't running. Per-step failures are logged
|
||||
/// but don't abort the sweep.
|
||||
///
|
||||
|
|
@ -1648,7 +1893,9 @@ 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.
|
||||
// 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.
|
||||
let as_token = match read_appservice_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
|
|
@ -1656,7 +1903,8 @@ pub async fn ensure_all() -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// One HTTP client for the whole sweep.
|
||||
// One HTTP client for the whole sweep — connection pool is
|
||||
// reused across agents.
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.build()
|
||||
|
|
@ -1684,6 +1932,7 @@ 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());
|
||||
}
|
||||
|
||||
|
|
@ -1852,4 +2101,30 @@ 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,25 +539,36 @@ 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)
|
||||
.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()));
|
||||
} 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)
|
||||
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!(
|
||||
|
|
@ -565,12 +576,15 @@ async fn handle_matrix_create_user(
|
|||
));
|
||||
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());
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ rmcp.workspace = true
|
|||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
//! matrix-sdk `Client` setup for the daemon: take the per-agent access
|
||||
//! token [`crate::credential`] resolved, probe `whoami` to recover the
|
||||
//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
|
||||
//! session, return the Client ready to start sync.
|
||||
//!
|
||||
//! 🩸 The token arrives as a [`Token`] and never as a path this module
|
||||
//! reads — where it came from is [`crate::credential`]'s question, and the
|
||||
//! only thing said about it here is its [`crate::credential::Origin`].
|
||||
//! matrix-sdk `Client` setup for the daemon: read the per-agent access
|
||||
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for`
|
||||
//! wrote, probe `whoami` to recover the agent's matrix `user_id` +
|
||||
//! `device_id`, restore the matrix-sdk session, return the Client ready
|
||||
//! to start sync.
|
||||
//!
|
||||
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
||||
//! ccc.de connection): for the swarm's tuwunel the swarm already minted the
|
||||
//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
|
||||
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
|
||||
//! the token + user/device as the hive's appservice and handed us the
|
||||
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
|
||||
//! `MatrixSession` skips the login flow entirely.
|
||||
//!
|
||||
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
||||
|
|
@ -36,12 +34,10 @@ use matrix_sdk::{
|
|||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::credential::Token;
|
||||
|
||||
/// Sentinel returned when `build_and_restore` detects that the token is
|
||||
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
|
||||
/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
|
||||
/// account needs re-provisioning.
|
||||
/// removed from disk. Callers should NOT retry — the account needs
|
||||
/// re-provisioning by hive-c0re.
|
||||
///
|
||||
/// Distinct from the general `anyhow::Error` path so callers can use
|
||||
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
|
||||
|
|
@ -68,53 +64,63 @@ struct WhoamiResponse {
|
|||
}
|
||||
|
||||
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
|
||||
/// `token`. The Client points at `homeserver`, persists its sqlite cache
|
||||
/// under `state_dir`, and is ready for sync once returned.
|
||||
/// token at `token_file`. The Client points at `homeserver`, persists
|
||||
/// its sqlite cache under `state_dir`, and is ready for sync once
|
||||
/// returned.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
|
||||
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||
/// the bearer — this gives us back the matrix `user_id` +
|
||||
/// `device_id` (the registration response had them but only the
|
||||
/// token was kept; whoami is the cheapest recovery path
|
||||
/// `device_id` (the registration response had them but hive-c0re
|
||||
/// only persisted the token; whoami is the cheapest recovery path
|
||||
/// and avoids matrix-sdk's circular requirement of needing a
|
||||
/// session to call whoami).
|
||||
/// 2. Build the real Client with the sqlite store + `restore_session`
|
||||
/// 3. Build the real Client with the sqlite store + `restore_session`
|
||||
/// using a synthetic `MatrixSession`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the `whoami` request fails, or if the matrix-sdk
|
||||
/// client fails to build or restore the session.
|
||||
/// Returns an error if the token file is missing or empty, if the
|
||||
/// `whoami` request fails, or if the matrix-sdk client fails to build
|
||||
/// or restore the session.
|
||||
pub async fn build_and_restore(
|
||||
homeserver: &str,
|
||||
token: &Token,
|
||||
token_file: &Path,
|
||||
state_dir: &Path,
|
||||
is_primary: bool,
|
||||
) -> Result<Client> {
|
||||
let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
|
||||
let token = fs::read_to_string(token_file)
|
||||
.await
|
||||
.with_context(|| format!("read matrix token from {}", token_file.display()))?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if token.is_empty() {
|
||||
return Err(anyhow!("matrix token at {} is empty", token_file.display()));
|
||||
}
|
||||
|
||||
let (user_id, device_id) = match whoami(homeserver, &token).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
if msg.contains("M_UNKNOWN_TOKEN") {
|
||||
// Homeserver rejected our token — stale session after a homeserver
|
||||
// state wipe or token expiry. Discard it so we don't loop on it
|
||||
// (which for a store-held credential is a log line and nothing
|
||||
// else — see `Token::discard_stale`). The rest of the handling
|
||||
// depends on whether this is the primary (hive-internal) account
|
||||
// or a secondary one, because a single bad secondary token must
|
||||
// NOT take down the whole daemon (and with it every healthy
|
||||
// account).
|
||||
// state wipe or token expiry. Delete the stale token file so we
|
||||
// don't loop on it. The rest of the handling depends on whether
|
||||
// this is the primary (hive-internal) account or a secondary one,
|
||||
// because a single bad secondary token must NOT take down the
|
||||
// whole daemon (and with it every healthy account).
|
||||
tracing::warn!(
|
||||
credential = %token.origin(),
|
||||
path = %token_file.display(),
|
||||
is_primary,
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
|
||||
);
|
||||
token.discard_stale().await;
|
||||
let _ = fs::remove_file(token_file).await;
|
||||
if is_primary {
|
||||
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
||||
// now-invalid session, then exit 0; the swarm's backfill
|
||||
// re-mints the token and systemd restarts us once it
|
||||
// exists (store re-check timer, or the path watcher). Exit 0 (not
|
||||
// now-invalid session, then exit 0 so hive-c0re's `ensure_all`
|
||||
// re-provisions the account and the systemd.paths watcher
|
||||
// restarts us once the fresh token file appears. Exit 0 (not
|
||||
// Err) keeps systemd's Restart=on-failure from looping; the
|
||||
// call site is before any tasks are spawned so there's
|
||||
// nothing to clean up.
|
||||
|
|
@ -127,13 +133,13 @@ pub async fn build_and_restore(
|
|||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Secondary: token discarded (so it's cleanly skipped next boot
|
||||
// Secondary: token removed (so it's cleanly skipped next boot
|
||||
// rather than re-erroring); leave the sdk state in place in case
|
||||
// the operator re-provisions a fresh token for the same device.
|
||||
// Return a PermanentBringUpError so the caller can distinguish
|
||||
// "don't retry" from a transient network/DNS failure.
|
||||
return Err(anyhow::Error::new(PermanentBringUpError(
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(),
|
||||
)));
|
||||
}
|
||||
return Err(e);
|
||||
|
|
@ -153,7 +159,7 @@ pub async fn build_and_restore(
|
|||
let session = MatrixSession {
|
||||
meta: SessionMeta { user_id, device_id },
|
||||
tokens: SessionTokens {
|
||||
access_token: token.expose().to_owned(),
|
||||
access_token: token,
|
||||
refresh_token: None,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,429 +0,0 @@
|
|||
//! The matrix access token as a value this process holds, and the two places
|
||||
//! it comes from.
|
||||
//!
|
||||
//! 🩸 **A secret is a path, not a value.** Nothing here writes the token
|
||||
//! anywhere, interpolates it into a command, or lets it reach a log line or an
|
||||
//! error message: [`Token`] has no `Debug` derive, and every message below
|
||||
//! names an [`Origin`] — which is a path in the store or a path on disk, never
|
||||
//! the bytes at either.
|
||||
//!
|
||||
//! The store read is this agent reading **its own** credential under **its own
|
||||
//! identity, from inside its own container** — the path
|
||||
//! `swarm/agents/<agent>/matrix/<account>` is already agent-scoped, and the
|
||||
//! certificate the login presents is the one this agent's hive delivered as a
|
||||
//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the
|
||||
//! path of the value at all.
|
||||
//!
|
||||
//! The file arm is the hive-side delivery that still runs beside this one for
|
||||
//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a
|
||||
//! hive minted before the swarm did. That is what this replaces, not something
|
||||
//! it depends on, and it is the arm that goes when the hive-side loop does.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
||||
matrix, policy,
|
||||
};
|
||||
|
||||
/// Names the agent this container belongs to, set by
|
||||
/// `nix/agent-modules/matrix.nix` from the agent's own unix user name.
|
||||
///
|
||||
/// The name and nothing else: `swarm_secret_client` owns both spellings
|
||||
/// derived from it — the credential's path ([`matrix::account_path`]) and the
|
||||
/// cert-auth role the login selects ([`policy::agent_object_name`]) — so a
|
||||
/// path or a role name forwarded from nix would be a second copy of a string
|
||||
/// whose mismatch is a 403 that names neither.
|
||||
pub const ENV_AGENT: &str = "HIVE_AGENT_NAME";
|
||||
|
||||
/// Where a token came from.
|
||||
///
|
||||
/// A **path** in both arms, which is what makes it safe to put in a log line:
|
||||
/// the whole point of this module is that the thing beside it never is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
/// A file in this agent's state dir, written by the hive-side delivery.
|
||||
File(PathBuf),
|
||||
/// A path in the swarm secret store, read by this agent as itself.
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Origin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::File(p) => write!(f, "{}", p.display()),
|
||||
Self::Store(p) => write!(f, "the store's {p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A matrix access token, held in memory for as long as it takes to restore a
|
||||
/// session with it.
|
||||
///
|
||||
/// ⚠️ **No `Debug` derive**, for the reason
|
||||
/// [`swarm_secret_client::mtls::Credential`] states for the private key it
|
||||
/// carries: this value is threaded through `anyhow` context chains and
|
||||
/// `tracing` fields, both of which format whatever they are handed. The
|
||||
/// hand-written impl below reports the origin and the length, which is every
|
||||
/// question a reader of a log line actually has.
|
||||
pub struct Token {
|
||||
value: String,
|
||||
origin: Origin,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Token {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Token")
|
||||
.field("origin", &self.origin)
|
||||
.field(
|
||||
"value",
|
||||
&format_args!("<redacted, {} bytes>", self.value.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Token {
|
||||
/// The bytes, for the one caller that has to present them to the
|
||||
/// homeserver.
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// Where this token came from — the only half of it that may be printed.
|
||||
#[must_use]
|
||||
pub fn origin(&self) -> &Origin {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
/// Read this agent's credential for `account` out of the swarm secret
|
||||
/// store, under this agent's own certificate.
|
||||
///
|
||||
/// `Ok(None)` means this deployment has no store: the agent's hive was
|
||||
/// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no
|
||||
/// identity check and there is nothing here to log in to. That is an absent
|
||||
/// integration, not a failure — the caller falls back to the file the hive
|
||||
/// delivered.
|
||||
///
|
||||
/// # Errors
|
||||
/// A name that is not a single path segment, an environment naming an
|
||||
/// identity that cannot be read, a store that refuses the certificate or
|
||||
/// the path, or a credential stored empty.
|
||||
pub async fn from_store(agent: &str, account: &str) -> Result<Option<Self>> {
|
||||
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = matrix::account_path(agent, account)
|
||||
.context("building this agent's credential path in the store")?;
|
||||
let role = policy::agent_object_name(agent)
|
||||
.context("building this agent's cert-auth role name")?;
|
||||
|
||||
let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.context("logging in to the swarm secret store as this agent")?;
|
||||
// The stored object also carries the account's homeserver, and it is
|
||||
// deliberately dropped here: the account's URL is already settled by
|
||||
// the time this runs (`AccountCfg::homeserver`), and taking it from the
|
||||
// store instead would change which accounts come up at all — that is
|
||||
// the discovery half of this move, which goes with the hive-side loop
|
||||
// rather than with the read.
|
||||
let credential: matrix::Credential = store
|
||||
.read(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path} from the store"))?;
|
||||
|
||||
let value = credential.value.trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("the credential at {path} in the store is empty"));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
value,
|
||||
origin: Origin::Store(path),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read a token out of the file the hive-side delivery wrote.
|
||||
///
|
||||
/// `Ok(None)` when the file is not there — the account has not been
|
||||
/// provisioned yet, which the caller treats as "skip", not "fail".
|
||||
///
|
||||
/// # Errors
|
||||
/// The file existing but being unreadable or empty.
|
||||
pub async fn from_file(path: &Path) -> Result<Option<Self>> {
|
||||
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read matrix token from {}", path.display()))?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("matrix token at {} is empty", path.display()));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
value,
|
||||
origin: Origin::File(path.to_owned()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Drop this token after the homeserver rejected it, so a restart does not
|
||||
/// loop on the same dead value.
|
||||
///
|
||||
/// Only a file can be dropped. A credential in the store is not this
|
||||
/// agent's to delete — its policy grants `read` and nothing else — and
|
||||
/// replacing it is renewal, a different job from this one. Saying so is the
|
||||
/// whole of what the store arm does.
|
||||
pub async fn discard_stale(&self) {
|
||||
match &self.origin {
|
||||
Origin::File(p) => {
|
||||
let _ = tokio::fs::remove_file(p).await;
|
||||
}
|
||||
Origin::Store(path) => tracing::warn!(
|
||||
credential = %path,
|
||||
"the stored matrix credential was rejected; leaving it alone (this agent may only read it)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This agent's name, or `None` when the harness did not say.
|
||||
///
|
||||
/// `None` is not a failure: it is what an agent whose harness predates
|
||||
/// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its
|
||||
/// hive delivers.
|
||||
#[must_use]
|
||||
pub fn agent_name() -> Option<String> {
|
||||
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// The store's coordinates as this container was given them, or `None` when it
|
||||
/// was given none.
|
||||
///
|
||||
/// Split out of [`Token::from_store`] and taken through a lookup so the two
|
||||
/// decisions it makes — "is there a store at all" and "is the delivered CA
|
||||
/// usable" — can be asserted without a store to talk to or an environment to
|
||||
/// mutate.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but
|
||||
/// the identity beside it was not, which is a half-delivered container rather
|
||||
/// than one without a store.
|
||||
fn store_settings(get: impl Fn(&str) -> Option<String>) -> Result<Option<Settings>> {
|
||||
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
||||
.context("reading the swarm secret store's coordinates from the environment")?;
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
/// Whether the CA bundle at `path` is a file with bytes in it.
|
||||
///
|
||||
/// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form
|
||||
/// is non-fatal when the manager received no such credential — so the variable
|
||||
/// can name a file that is not there. Absent means "verify the store's listener
|
||||
/// against the container's own trust store", which is what a deployment with a
|
||||
/// real CA wants; handing the path through regardless would fail the TLS
|
||||
/// handshake on a file that was never meant to exist and blame the store.
|
||||
fn ca_is_usable(path: &str) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lookup standing in for a container that was handed a store.
|
||||
fn with_store(k: &str) -> Option<String> {
|
||||
match k {
|
||||
ENV_ADDR => Some("https://bao.t.local:8200".to_owned()),
|
||||
"BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()),
|
||||
"BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the hand-written `Debug` exists for: a token that reaches a
|
||||
/// log line is a token in a journal somebody else can read.
|
||||
#[test]
|
||||
fn formatting_a_token_does_not_reveal_it() {
|
||||
let token = Token {
|
||||
value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(),
|
||||
origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()),
|
||||
};
|
||||
let rendered = format!("{token:?}");
|
||||
assert!(
|
||||
!rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"),
|
||||
"the token must not survive formatting, got {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("<redacted"), "got {rendered}");
|
||||
// The control: the origin IS meant to survive, so the assertion above
|
||||
// is not passing because the impl prints nothing at all.
|
||||
assert!(
|
||||
rendered.contains("swarm/agents/a1/matrix/ccc"),
|
||||
"got {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same property for the arm a reader actually reaches for: `Display`
|
||||
/// on the origin is what every message in this crate interpolates.
|
||||
#[test]
|
||||
fn an_origin_renders_as_a_path_in_both_arms() {
|
||||
assert_eq!(
|
||||
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
|
||||
"/agents/a1/state/matrix-token-ccc"
|
||||
);
|
||||
assert_eq!(
|
||||
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
|
||||
"the store's swarm/agents/a1/matrix/ccc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_with_no_store_address_reads_no_store() {
|
||||
// The absence arm, and what the file fallback exists for: a hive that
|
||||
// was given no store forwards nothing, and this must be "there is no
|
||||
// store" rather than an error about a missing certificate.
|
||||
assert!(
|
||||
store_settings(|_| None)
|
||||
.expect("an absent store is not a failure")
|
||||
.is_none()
|
||||
);
|
||||
// Empty is how systemd delivers an unset nix option, so it has to read
|
||||
// the same as unset.
|
||||
assert!(
|
||||
store_settings(|k| if k == ENV_ADDR {
|
||||
Some(String::new())
|
||||
} else {
|
||||
with_store(k)
|
||||
})
|
||||
.expect("an empty address is not an address")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_without_an_identity_is_a_half_delivered_container() {
|
||||
// Not the same thing as having no store: the hive said where the store
|
||||
// is and then delivered nothing to present to it, which is a deployment
|
||||
// bug and must be named rather than silently degraded.
|
||||
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
|
||||
.expect_err("an address with no certificate beside it is a failure");
|
||||
assert!(e.to_string().contains("coordinates"), "got {e:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_address_with_an_identity_is_usable() {
|
||||
// The control for the two arms above.
|
||||
assert!(
|
||||
store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
|
||||
// `LoadCredential=` in its bare form is non-fatal when the credential
|
||||
// is absent, so this variable routinely names a file that is not there.
|
||||
// The resulting settings must be the ones a container with no CA at all
|
||||
// gets — anything else fails the handshake and blames the store.
|
||||
let named = store_settings(|k| {
|
||||
if k == ENV_CACERT {
|
||||
Some("/nonexistent/server-ca.pem".to_owned())
|
||||
} else {
|
||||
with_store(k)
|
||||
}
|
||||
})
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
let absent = store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
assert_eq!(named, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ca_with_bytes_in_it_is_kept() {
|
||||
// The control: without it, the test above would pass against a
|
||||
// correction that dropped every CA it was ever given.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-cred-ca-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ca = dir.join("server-ca.pem");
|
||||
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
|
||||
assert!(ca_is_usable(ca.to_str().unwrap()));
|
||||
|
||||
let kept = store_settings(|k| {
|
||||
if k == ENV_CACERT {
|
||||
Some(ca.to_string_lossy().into_owned())
|
||||
} else {
|
||||
with_store(k)
|
||||
}
|
||||
})
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
assert_ne!(
|
||||
kept,
|
||||
store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.expect("configured"),
|
||||
"a delivered CA must change the settings it is delivered into"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
|
||||
// The daemon skips such an account (a secondary) or waits on the path
|
||||
// watcher (the primary); neither is a failure.
|
||||
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
|
||||
assert!(Token::from_file(&missing).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-cred-empty-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("matrix-token");
|
||||
std::fs::write(&path, " \n").unwrap();
|
||||
|
||||
let e = Token::from_file(&path)
|
||||
.await
|
||||
.expect_err("whitespace is not a token");
|
||||
assert!(
|
||||
e.to_string().contains(&path.display().to_string()),
|
||||
"got {e:#}"
|
||||
);
|
||||
|
||||
// And the control: a real token comes back, trimmed, with the file as
|
||||
// its origin.
|
||||
std::fs::write(&path, "syt_abc\n").unwrap();
|
||||
let token = Token::from_file(&path)
|
||||
.await
|
||||
.expect("a readable file")
|
||||
.expect("a non-empty one");
|
||||
assert_eq!(token.expose(), "syt_abc");
|
||||
assert_eq!(token.origin(), &Origin::File(path.clone()));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
pub mod accounts;
|
||||
pub mod client;
|
||||
pub mod credential;
|
||||
pub mod handlers;
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
|
|
|
|||
|
|
@ -7,24 +7,22 @@
|
|||
//! Lifecycle:
|
||||
//! 1. Read the configured account list (`accounts::configured()` —
|
||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
||||
//! 2. For each account: resolve its access token (`account_token` — the
|
||||
//! swarm secret store first, read by this agent as itself, then the
|
||||
//! file its hive delivered) → whoami probe → recover `user_id` + `device_id`
|
||||
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
|
||||
//! → restore matrix-sdk session (no login flow), install the
|
||||
//! message-event handler, and spawn its own sync loop.
|
||||
//! 3. Serve the MCP tools against an account→Client registry; each tool
|
||||
//! call routes to the account named in its `account` arg (the
|
||||
//! primary account when omitted).
|
||||
//!
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token →
|
||||
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
|
||||
//! a token file, the store re-check timer for a stored token). A SECONDARY
|
||||
//! account missing its token is skipped (the daemon still serves the others).
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token file →
|
||||
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
|
||||
//! provisions it. A SECONDARY account missing its token is skipped (the
|
||||
//! daemon still serves the others).
|
||||
//!
|
||||
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
|
||||
//! `client::build_and_restore`, and it mirrors the missing-token policy
|
||||
//! above — a rejected PRIMARY token drops the stale token + sdk state and
|
||||
//! exits 0 until a fresh token exists, while a rejected SECONDARY token
|
||||
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
|
||||
//! is removed and that one account is skipped so the daemon keeps serving
|
||||
//! the primary and any other healthy account.
|
||||
|
||||
|
|
@ -36,8 +34,7 @@ use matrix_sdk::{Client, config::SyncSettings};
|
|||
|
||||
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
|
||||
use hive_matrix_mcp::client::PermanentBringUpError;
|
||||
use hive_matrix_mcp::credential::Token;
|
||||
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
|
||||
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
|
||||
|
|
@ -100,13 +97,13 @@ async fn main() -> Result<()> {
|
|||
sync_loops.push(sync_loop);
|
||||
}
|
||||
// No token yet: for the primary that means the daemon isn't
|
||||
// useful — exit 0, and systemd restarts us once a token exists
|
||||
// (the path-watcher for a file, the re-check timer for the store).
|
||||
// useful — exit 0 like the legacy single-account path so the
|
||||
// systemd path-watcher restarts us when the token appears.
|
||||
Ok(None) if is_primary => {
|
||||
tracing::warn!(
|
||||
account = %cfg.name,
|
||||
"primary matrix account has no token yet; exiting cleanly \
|
||||
(systemd restarts us once one exists)"
|
||||
(systemd restarts us when hive-c0re provisions it)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -262,46 +259,6 @@ async fn bring_up_secondary_with_retry(
|
|||
None
|
||||
}
|
||||
|
||||
/// Resolve `cfg`'s access token, preferring the copy this agent can fetch
|
||||
/// itself.
|
||||
///
|
||||
/// 🏛️ The store read is the point: the credential at
|
||||
/// `swarm/agents/<agent>/matrix/<account>` is **this agent's**, and it is read
|
||||
/// from inside this agent's container under this agent's own certificate,
|
||||
/// never by the hive on the agent's behalf. It is held in memory from here to
|
||||
/// `restore_session` and is written nowhere.
|
||||
///
|
||||
/// `main` included: `swarm-controller` mints it with the swarm's appservice
|
||||
/// token and stores it at `swarm/agents/<agent>/matrix/main`, and no hive
|
||||
/// mints it any more. An agent whose harness forwards no
|
||||
/// [`credential::ENV_AGENT`] cannot name its own subtree, so it reads the file.
|
||||
///
|
||||
/// The file is what remains when the store answers nothing: an extra account
|
||||
/// the hive-side delivery loop still writes, or a `main` token a hive minted
|
||||
/// before the swarm did. A store read that *fails* falls back too, and says
|
||||
/// why.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`credential::Token::from_file`]: a token file that exists but cannot be
|
||||
/// read, or is empty.
|
||||
async fn account_token(cfg: &AccountCfg) -> Result<Option<Token>> {
|
||||
if let Some(agent) = credential::agent_name() {
|
||||
match Token::from_store(&agent, &cfg.name).await {
|
||||
Ok(Some(token)) => return Ok(Some(token)),
|
||||
// No store in this deployment: nothing to report, the hive-side
|
||||
// delivery is the whole mechanism here.
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
account = %cfg.name,
|
||||
error = %format!("{e:#}"),
|
||||
"could not read this account's credential from the store as this agent; \
|
||||
falling back to the token the hive delivered"
|
||||
),
|
||||
}
|
||||
}
|
||||
Token::from_file(&cfg.token_file).await
|
||||
}
|
||||
|
||||
/// Restore one account's client (when its token exists), install its
|
||||
/// message handler, and build its sync loop. Returns
|
||||
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
|
||||
|
|
@ -323,17 +280,21 @@ async fn bring_up_account(
|
|||
);
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(token) = account_token(cfg).await? else {
|
||||
if !tokio::fs::try_exists(&cfg.token_file)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
};
|
||||
}
|
||||
tracing::info!(
|
||||
account = %cfg.name,
|
||||
homeserver,
|
||||
credential = %token.origin(),
|
||||
token_file = %cfg.token_file.display(),
|
||||
state_dir = %cfg.state_dir.display(),
|
||||
"bringing up matrix account"
|
||||
);
|
||||
let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
|
||||
let client =
|
||||
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
|
||||
.await
|
||||
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
|
||||
// Best-effort: sync the agent icon to this account's matrix avatar over
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use std::path::PathBuf;
|
|||
|
||||
/// Resolve the matrix access-token file path. Override via
|
||||
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
|
||||
/// where a hive used to write the `main` account's token (the daemon now reads
|
||||
/// it from the store first; see `main.rs`'s `account_token`).
|
||||
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent
|
||||
/// account provisioning.
|
||||
#[must_use]
|
||||
pub fn token_file() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {
|
||||
|
|
|
|||
|
|
@ -286,15 +286,15 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MatrixCmd {
|
||||
/// Create a matrix account for a person or other non-agent `<name>` and
|
||||
/// print its access token to stdout.
|
||||
/// Create or refresh the matrix account + access token for `<name>`.
|
||||
///
|
||||
/// 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).
|
||||
/// 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).
|
||||
CreateUser {
|
||||
/// Matrix localpart of a non-agent account — `mara`, `damocles`, etc.
|
||||
/// Matrix localpart. For agents: the container/agent name.
|
||||
/// For humans: any matrix localpart — `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
|
||||
|
|
|
|||
|
|
@ -14,21 +14,12 @@
|
|||
}:
|
||||
let
|
||||
userName = config.services.hyperhive.agent.user.name;
|
||||
# This agent's own state dir, where a hive used to write the `main`
|
||||
# account's token (`matrix-token`, now the store's fallback) and the
|
||||
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
|
||||
# below and the path-watcher glob at the bottom of this file.
|
||||
# This agent's own state dir, where hive-c0re provisions the
|
||||
# hive-internal account's token (`matrix-token`) and the daemon keeps
|
||||
# its matrix-sdk store. Shared by the `main` account entry below and
|
||||
# the path-watcher glob at the bottom of this file.
|
||||
stateDir = "/agents/${userName}/state";
|
||||
accounts = config.services.hyperhive.agent.matrixAccounts;
|
||||
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
|
||||
# reads each account's token from the store itself, `main` included (the
|
||||
# swarm mints that one), so the store's coordinates belong on its unit. The
|
||||
# same three credential ids ./bao.nix and ./forge-token.nix load.
|
||||
baoCfg = config.services.hyperhive.agent.bao;
|
||||
storeConfigured = baoCfg.addr != null;
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
# **The enable signal.** Matrix is on for this agent exactly when it has at
|
||||
# least one account, because an account is the only thing the daemon has to
|
||||
# do: no account, no Client, no sync, no tool surface worth injecting.
|
||||
|
|
@ -99,9 +90,8 @@ in
|
|||
Path to this account's bearer-token file. The daemon reads
|
||||
the token from here to restore the matrix session; how the
|
||||
file gets populated is the provisioner's concern
|
||||
(for `main`, a file only when the store has no token: the
|
||||
swarm mints `main` into the store; an operator-supplied
|
||||
secret for an external one). The daemon
|
||||
(hive-c0re for the hive-internal `main` account, an
|
||||
operator-supplied secret for an external one). The daemon
|
||||
skips an extra account whose token file is absent, and
|
||||
exits cleanly to wait on the path watcher when `main`'s is.
|
||||
'';
|
||||
|
|
@ -153,7 +143,7 @@ in
|
|||
homeserver (`homeserver`, else
|
||||
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
|
||||
account whose URL or token file is missing, and a `systemd.paths`
|
||||
watcher restarts it the moment a token file appears
|
||||
watcher restarts it the moment hive-c0re provisions the token
|
||||
(same path-trigger shape as `forge-avatar-sync`).
|
||||
- exposes the matrix tool surface (send_message, send_dm,
|
||||
send_reaction, send_reply, mark_read, list_rooms,
|
||||
|
|
@ -183,8 +173,8 @@ in
|
|||
like any other, so it shows up in the account list --- what you
|
||||
add here are the *further* accounts (e.g. an external
|
||||
public-matrix account). Its `tokenFile` stays pinned to
|
||||
`<state>/matrix-token` (an assertion; the file the daemon falls back
|
||||
to when the store has no `main` token), and the
|
||||
`<state>/matrix-token` (an assertion; that is the one path
|
||||
hive-c0re provisions the hive-internal token to), and the
|
||||
dashboard's link-account route refuses to create an account named
|
||||
`main` --- the entry belongs to the module, not to a provisioner.
|
||||
|
||||
|
|
@ -225,8 +215,8 @@ in
|
|||
# `main` is not a forbidden key --- this module declares it
|
||||
# itself (see the `matrixAccounts.main` definition below), so the
|
||||
# name must be allowed. What stays rejected is retargeting *its
|
||||
# token file*: the daemon's file fallback for `main` is
|
||||
# `<state>/matrix-token` and nothing else, so an override there
|
||||
# token file*: hive-c0re writes the hive-internal account's token
|
||||
# to `<state>/matrix-token` and nowhere else, so an override there
|
||||
# is an account that evaluates fine and then never restores. The
|
||||
# other two fields are free to override (a `mkDefault` each).
|
||||
#
|
||||
|
|
@ -237,8 +227,8 @@ in
|
|||
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
|
||||
message =
|
||||
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
|
||||
+ "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
|
||||
+ "main account's token. Declare a separate account instead of "
|
||||
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
|
||||
+ "hive-internal account's token. Declare a separate account instead of "
|
||||
+ "pointing `main` elsewhere.";
|
||||
}
|
||||
# Token files must land at the `matrix-token*` name the daemon
|
||||
|
|
@ -270,7 +260,7 @@ in
|
|||
# so the one it always has belongs in it. `mkDefault` per field so an
|
||||
# operator can retarget e.g. the homeserver without a
|
||||
# conflicting-definition error (the token file is pinned by an
|
||||
# assertion above, since the daemon's fallback reads that path).
|
||||
# assertion above, since hive-c0re owns that path).
|
||||
#
|
||||
# ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled`
|
||||
# from being trivially true for every agent in the hive. A `main` with no
|
||||
|
|
@ -319,20 +309,6 @@ in
|
|||
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
||||
RUST_LOG = "info";
|
||||
}
|
||||
# The store's coordinates and which agent this is, so the daemon reads
|
||||
# `swarm/agents/<agent>/matrix/<account>` as itself. The NAME and not a
|
||||
# path: `swarm_secret_client` builds both the path and the cert-auth role
|
||||
# from it. Paths only — `%d` is this unit's own credentials directory.
|
||||
// lib.optionalAttrs storeConfigured {
|
||||
HIVE_AGENT_NAME = userName;
|
||||
BAO_ADDR = baoCfg.addr;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
# Named even when no CA was delivered: a bare `LoadCredential=` is
|
||||
# non-fatal when absent, and the daemon treats a missing or empty file
|
||||
# as "use the container's own trust store".
|
||||
BAO_CACERT = "%d/${serverCaCredential}";
|
||||
}
|
||||
# Homeserver URL. hive-c0re writes this option per agent from the
|
||||
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
|
||||
# by default — agents run in a private
|
||||
|
|
@ -381,8 +357,7 @@ in
|
|||
# `on-failure`, not `always`: the daemon deliberately exits 0
|
||||
# (a clean, non-failure exit) when no token is provisioned yet
|
||||
# (see the module doc above) — the `systemd.paths` watcher
|
||||
# below re-fires it the moment a token file appears (the timer
|
||||
# above, for a token in the store),
|
||||
# below re-fires it the moment hive-c0re provisions one,
|
||||
# instead of `always` busy-looping every `RestartSec` until
|
||||
# then. Once a token exists this is no different from
|
||||
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
|
||||
|
|
@ -392,33 +367,11 @@ in
|
|||
RestartSec = 5;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
}
|
||||
# This agent's own certificate, imported by id so systemd materialises it
|
||||
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
|
||||
// lib.optionalAttrs storeConfigured {
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Re-start the daemon while it is down, when its token lives in the store.
|
||||
# The path unit below only sees files, and a token the swarm mints or
|
||||
# replaces in the store changes no file: a daemon that exited on a missing
|
||||
# or replaced token would otherwise stay down until the container restarts.
|
||||
# Relative to the daemon's last exit, so it never fires while the daemon
|
||||
# runs; five minutes is the swarm's own re-mint cadence.
|
||||
systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) {
|
||||
description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig.OnUnitInactiveSec = "5min";
|
||||
};
|
||||
|
||||
# Re-fire the daemon when the matrix token appears (a token
|
||||
# file: an extra account the hive delivers, or a `main` from before the
|
||||
# swarm minted it). Without this
|
||||
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||
# provisions it after agent containers come up). Without this
|
||||
# the daemon would exit 0 silently on first boot and the MCP
|
||||
# would have no backend until next restart. See
|
||||
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue