Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
85ba45b2de docs: agents' matrix accounts come from the swarm
The integration, tool, persistence and setup pages described hive-c0re
minting each agent's token into its state dir. They now describe the
swarm's appservice, its admin sender, the controller's mint and pass, the
daemon's store read and re-start timer, and the two new credential rows.
ruth's matrix account comes from the same pass once she holds the store
identity the setup page already has the operator mint.
2026-09-25 08:31:01 +02:00
atlas
89a5dd752c 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.
2026-09-25 08:31:01 +02:00
atlas
ab153bda2f hive-matrix-mcp: read the main account's token from the store too
The daemon reads each account's token from `swarm/agents/<agent>/matrix/`
as the agent itself, inside its own container, and falls back to the file
only when the store has none. This is #4519's read, without its `main`
carve-out: the swarm now mints `main` there and no hive writes the file.

The daemon unit gets the agent's store identity, spelled the way
forge-token.nix spells it. A timer re-starts it while it is down: a token
the swarm mints or replaces in the store changes no file, so the path
watcher never fires for it, and a daemon that exited on a replaced token
would otherwise stay down until the container restarts.
2026-09-25 08:31:01 +02:00
atlas
2776e121e5 swarm-controller: mint each agent's matrix account with the swarm's token
A `MintAgentMatrixAccount` node creates the agent's account on the swarm's
homeserver with the swarm appservice token, stores its token at
`swarm/agents/<agent>/matrix/main`, and reads it back with whoami before
reporting success. It is a root of agent creation, `after_any` into the
deploy, and a five-minute backfill over every agent with a store identity
queues the same node — the shape of the forge-token mint.

The decision reads the stored token back rather than only checking that one
is stored: the swarm and a hive both pin the device `hyperhive-<agent>`, so
each login replaces the other's token. A failed read plans nothing, so an
outage never rotates every agent's token.

`matrixHomeserverUrl` now defaults to the swarm's `chat.` vhost, since the
mint is what consults it.
2026-09-25 08:31:01 +02:00
atlas
9308752a09 hive-matrix: load the swarm's appservice and promote its sender
The matrix container renders the swarm registration before tuwunel starts
(`requiredBy` it, no network), tuwunel loads it as a second `.yaml`
credential, and a publish unit hands its token to the store. tuwunel 1.9.1
refuses only a duplicate id or as_token, not overlapping non-exclusive
namespaces, so it sits beside the hive's `hyperhive` registration.

`admin_execute` promotes exactly `@swarm` at boot. The module-eval pin
narrows from "no account" to that one list, compared whole so a second
entry fails; `admin_execute_errors_ignore` is pinned too. matrix-ctl may
write the swarm token's leaf, and the controller may only read it.

Everything is gated on matrix-ctl's store identity: with nobody to publish
the token, the registration would be an admin credential nobody reads.
2026-09-25 08:31:01 +02:00
atlas
89aff8d613 swarm-matrix-ctl: mint the swarm's own appservice registration
The swarm gets an appservice identity of its own, separate from each hive's
`hyperhive` registration. `swarm-matrix-ctl appservice render` mints its
tokens inside the matrix container when they are absent and renders the
registration tuwunel loads; `appservice publish` writes its as_token to
`swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no
hive's policy grants.

The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client,
with a `whoami`, so swarm-controller can mint agents' accounts through the
same pinned device id instead of a copy of them.
2026-09-25 08:31:01 +02:00
39 changed files with 2164 additions and 572 deletions

13
Cargo.lock generated
View file

@ -1989,6 +1989,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"swarm-secret-client",
"tokio",
"tracing",
"tracing-subscriber",
@ -4913,6 +4914,7 @@ dependencies = [
"sha2 0.11.0",
"strum",
"swarm-authelia-bridge-sock",
"swarm-matrix-client",
"swarm-queue-client",
"swarm-secret-client",
"time",
@ -4935,14 +4937,23 @@ 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",

View file

@ -27,6 +27,7 @@ members = [
"swarm-authelia-bridge",
"swarm-authelia-bridge-sock",
"swarm-controller",
"swarm-matrix-client",
"swarm-matrix-ctl",
"swarm-nats-auth",
"swarm-queue-client",
@ -101,6 +102,7 @@ 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"

View file

@ -569,8 +569,9 @@ 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**: hive-c0re provisions the matrix token AFTER
agent containers come up. Without the path-trigger sibling
**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
(`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
@ -578,7 +579,9 @@ 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. The same token watcher also drives avatar setting: on a
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
restart the daemon re-runs each account's bring-up, which sets the
avatar (see below).

View file

@ -44,7 +44,8 @@ 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. `swarmctl agent mint-forge-token ruth` skips the wait for
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
the pass. A hive without a swarm secret store has no path to a forge token
for ruth at all.
@ -237,10 +238,6 @@ 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'
@ -249,6 +246,12 @@ 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`).

View file

@ -129,31 +129,52 @@ 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 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.
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.
### 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, and the
homeserver runs no `admin_execute` for it.
anything. It's an **ordinary account**: nothing promotes it; the
homeserver's `admin_execute` promotes only `@swarm`.
**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
@ -185,10 +206,8 @@ 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 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.
every other call site would also carry. The swarm's own sender is the
admin they need; moving them there is separate work.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
@ -239,9 +258,6 @@ 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

View file

@ -54,20 +54,35 @@ 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/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `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 |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
| 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 |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
<!-- 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

View file

@ -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:**

View file

@ -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

View file

@ -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. 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.
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.
<!-- vale write-good.Passive = YES -->
`main` is present exactly when that URL is non-null, which is the whole
@ -122,9 +122,10 @@ 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 `<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.
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.
Incoming room events wake the agent via `AgentRequest::Wake` with
`from: "matrix"`. The wake body format depends on the unread state:

View file

@ -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.

View file

@ -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));
}
}

View file

@ -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))
}

View file

@ -21,6 +21,7 @@ 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

View file

@ -1,13 +1,15 @@
//! 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.
//! 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`].
//!
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
//! 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
//! 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
//! `MatrixSession` skips the login flow entirely.
//!
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
@ -34,10 +36,12 @@ 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
/// removed from disk. Callers should NOT retry — the account needs
/// re-provisioning by hive-c0re.
/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
/// account needs re-provisioning.
///
/// Distinct from the general `anyhow::Error` path so callers can use
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
@ -64,63 +68,53 @@ struct WhoamiResponse {
}
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// token at `token_file`. The Client points at `homeserver`, persists
/// its sqlite cache under `state_dir`, and is ready for sync once
/// returned.
/// `token`. The Client points at `homeserver`, persists its sqlite cache
/// under `state_dir`, and is ready for sync once returned.
///
/// Steps:
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// 1. 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 hive-c0re
/// only persisted the token; whoami is the cheapest recovery path
/// `device_id` (the registration response had them but only the
/// token was kept; whoami is the cheapest recovery path
/// and avoids matrix-sdk's circular requirement of needing a
/// session to call whoami).
/// 3. Build the real Client with the sqlite store + `restore_session`
/// 2. Build the real Client with the sqlite store + `restore_session`
/// using a synthetic `MatrixSession`.
///
/// # Errors
///
/// 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.
/// Returns an error 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_file: &Path,
token: &Token,
state_dir: &Path,
is_primary: bool,
) -> Result<Client> {
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 {
let (user_id, device_id) = match whoami(homeserver, token.expose()).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. 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).
// 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).
tracing::warn!(
path = %token_file.display(),
credential = %token.origin(),
is_primary,
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
);
let _ = fs::remove_file(token_file).await;
token.discard_stale().await;
if is_primary {
// Primary: also drop the matrix-sdk sqlite state keyed to the
// 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
// 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
// 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.
@ -133,13 +127,13 @@ pub async fn build_and_restore(
}
std::process::exit(0);
}
// Secondary: token removed (so it's cleanly skipped next boot
// Secondary: token discarded (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); removed stale token, skipping account".into(),
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(),
)));
}
return Err(e);
@ -159,7 +153,7 @@ pub async fn build_and_restore(
let session = MatrixSession {
meta: SessionMeta { user_id, device_id },
tokens: SessionTokens {
access_token: token,
access_token: token.expose().to_owned(),
refresh_token: None,
},
};

View file

@ -0,0 +1,429 @@
//! 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();
}
}

View file

@ -9,6 +9,7 @@
pub mod accounts;
pub mod client;
pub mod credential;
pub mod handlers;
pub mod mcp;
pub mod paths;

View file

@ -7,22 +7,24 @@
//! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
//! 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`
//! → 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 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).
//! 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).
//!
//! 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 for systemd re-provisioning, while a rejected SECONDARY token
//! exits 0 until a fresh token exists, 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.
@ -34,7 +36,8 @@ use matrix_sdk::{Client, config::SyncSettings};
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
use hive_matrix_mcp::client::PermanentBringUpError;
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
use hive_matrix_mcp::credential::Token;
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
#[derive(Parser)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
@ -97,13 +100,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 like the legacy single-account path so the
// systemd path-watcher restarts us when the token appears.
// useful — exit 0, and systemd restarts us once a token exists
// (the path-watcher for a file, the re-check timer for the store).
Ok(None) if is_primary => {
tracing::warn!(
account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \
(systemd restarts us when hive-c0re provisions it)"
(systemd restarts us once one exists)"
);
return Ok(());
}
@ -259,6 +262,46 @@ 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)`
@ -280,23 +323,19 @@ async fn bring_up_account(
);
return Ok(None);
};
if !tokio::fs::try_exists(&cfg.token_file)
.await
.unwrap_or(false)
{
let Some(token) = account_token(cfg).await? else {
return Ok(None);
}
};
tracing::info!(
account = %cfg.name,
homeserver,
token_file = %cfg.token_file.display(),
credential = %token.origin(),
state_dir = %cfg.state_dir.display(),
"bringing up matrix account"
);
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))?;
let client = client::build_and_restore(&homeserver, &token, &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
// the live (authenticated, correct-homeserver) Client. Replaces the old
// curl oneshot; failures are swallowed inside sync_avatar.

View file

@ -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`,
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent
/// account provisioning.
/// 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`).
#[must_use]
pub fn token_file() -> PathBuf {
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {

View file

@ -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

View file

@ -14,12 +14,21 @@
}:
let
userName = config.services.hyperhive.agent.user.name;
# 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.
# 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.
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.
@ -90,8 +99,9 @@ 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
(hive-c0re for the hive-internal `main` account, an
operator-supplied secret for an external one). The daemon
(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
skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is.
'';
@ -143,7 +153,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 hive-c0re provisions the token
watcher restarts it the moment a token file appears
(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,
@ -173,8 +183,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; that is the one path
hive-c0re provisions the hive-internal token to), and the
`<state>/matrix-token` (an assertion; the file the daemon falls back
to when the store has no `main` token), and the
dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner.
@ -215,8 +225,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*: hive-c0re writes the hive-internal account's token
# to `<state>/matrix-token` and nowhere else, so an override there
# token file*: the daemon's file fallback for `main` is
# `<state>/matrix-token` and nothing 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).
#
@ -227,8 +237,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 where hive-c0re provisions the "
+ "hive-internal account's token. Declare a separate account instead of "
+ "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
+ "main account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere.";
}
# Token files must land at the `matrix-token*` name the daemon
@ -260,7 +270,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 hive-c0re owns that path).
# assertion above, since the daemon's fallback reads 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
@ -309,6 +319,20 @@ 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
@ -357,7 +381,8 @@ 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 hive-c0re provisions one,
# below re-fires it the moment a token file appears (the timer
# above, for a token in the store),
# 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
@ -367,11 +392,33 @@ 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-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# 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
# 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