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

View file

@ -27,6 +27,7 @@ members = [
"swarm-authelia-bridge", "swarm-authelia-bridge",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-controller", "swarm-controller",
"swarm-matrix-client",
"swarm-matrix-ctl", "swarm-matrix-ctl",
"swarm-nats-auth", "swarm-nats-auth",
"swarm-queue-client", "swarm-queue-client",
@ -101,6 +102,7 @@ hive-priv-sock = { path = "hive-priv-sock" }
hive-sock-client = { path = "hive-sock-client" } hive-sock-client = { path = "hive-sock-client" }
hive-types = { path = "hive-types" } hive-types = { path = "hive-types" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
swarm-matrix-client = { path = "swarm-matrix-client" }
swarm-queue-client = { path = "swarm-queue-client" } swarm-queue-client = { path = "swarm-queue-client" }
swarm-secret-client = { path = "swarm-secret-client" } swarm-secret-client = { path = "swarm-secret-client" }
thiserror = "2" 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 agent has one; a `null` URL with no operator-declared account is the
"this agent has no matrix" state. "this agent has no matrix" state.
**First-boot ordering**: hive-c0re provisions the matrix token AFTER **First-boot ordering**: a token can arrive after the container comes
agent containers come up. Without the path-trigger sibling 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 = (`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
<this agent's state dir>/matrix-token*` — the trailing `*` also catches <this agent's state dir>/matrix-token*` — the trailing `*` also catches
a secondary multi-account token like `matrix-token-ccc`), the daemon 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 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 of the token re-fire the service so the daemon comes alive in the
same boot cycle as 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 restart the daemon re-runs each account's bring-up, which sets the
avatar (see below). 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 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 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 the pass. A hive without a swarm secret store has no path to a forge token
for ruth at all. for ruth at all.
@ -237,10 +238,6 @@ control: [`swarm/ui.md`](../swarm/ui.md).
# Ensure the appservice's sender account exists first # Ensure the appservice's sender account exists first
hivectl matrix sync-admin 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) # Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' 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 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 Swarm SSO creates the human operator's own matrix account instead of
a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`). 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, the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
so the sibling credentials are invisible to it. The `.yaml` suffix on so the sibling credentials are invisible to it. The `.yaml` suffix on
the credential id is what makes this work. the credential id is what makes this work.
5. **hive-c0re** reads the appservice token and creates each account with 5. **hive-c0re** reads the appservice token and creates its own
one `POST /register` typed `m.login.application_service`, persisting `@hive-<hive>:` account, and the accounts an operator asks for with
the returned `access_token` to `<agent-state>/matrix-token`. It never `hivectl matrix create-user`. It never mints the token itself: the value
mints the token itself: the value has to be the one the rendered has to be the one the rendered registration names, and only the nix
registration names, and only the nix side writes that. side writes that.
6. **An account that exists but has lost its token file** is re-tokened 6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
by an appservice `POST /login` — no password and no admin rights one with the swarm's own appservice token (next section), stores its
involved. A stored-password login and an admin-room password reset token at `swarm/agents/<agent>/matrix/main`, and the agent's
remain behind that, for accounts created before the appservice existed `hive-matrix-daemon` reads it from there as the agent itself.
or named outside its namespace.
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent ### The swarm's appservice, and its admin sender
immediately after writing the token so the daemon picks up the
new credential without waiting for a full container restart. If A second registration sits beside the hive's: id `swarm`, sender `@swarm`,
the restart fails (for example daemon not yet running on first boot) the same non-exclusive namespace. It's the swarm's identity on the
hive-c0re logs the error as a warning and the `.path`-trigger sibling homeserver, and `swarm-controller` creates every agent's account with it.
(`hive-matrix-daemon.path` watching for `matrix-token` appearance) tuwunel loads every `.yaml` in `appservice_dir` and refuses only a
brings the daemon up on the same boot cycle anyway. 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 ### The appservice's sender account, and why it isn't an admin
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`, `@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
which the homeserver creates itself when it loads the registration — on a which the homeserver creates itself when it loads the registration — on a
zero-user database, inside startup, before the HTTP listener accepts zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the anything. It's an **ordinary account**: nothing promotes it; the
homeserver runs no `admin_execute` for it. homeserver's `admin_execute` promotes only `@swarm`.
**One account per hive.** The localpart carries the hive's name, so a 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 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, 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 `@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 admin room's refusal rather than an over-privileged credential that
every other call site would also carry. The one hive-side path that every other call site would also carry. The swarm's own sender is the
depends on them is the automatic password recovery for an agent that admin they need; moving them there is separate work.
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> <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 that minted it; removing the registration token touches no
device, no account and no session. `login_with_password` stays on, so device, no account and no session. `login_with_password` stays on, so
the password fallback is still there too. 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 - **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 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 homeserver no longer promotes it, so a hive built fresh has an

View file

@ -55,8 +55,10 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | 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/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>/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>/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/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 |
@ -68,6 +70,19 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = YES --> <!-- 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 **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 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 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:** ###### **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 * `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
* `promote-user` — Promote a matrix user to homeserver admin * `promote-user` — Promote a matrix user to homeserver admin
* `reset-password` — Reset a matrix user's password via the admin API * `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` ## `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>` **Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
###### **Arguments:** ###### **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:** ###### **Options:**

View file

@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow
running (`services.hyperhive.deploy.matrix.enable = true`). running (`services.hyperhive.deploy.matrix.enable = true`).
```bash ```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 # 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 create-user mara --password hunter2 # set a client-login password
hivectl matrix sync-admin # provision / refresh the appservice's sender account 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 hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
``` ```
- `create-user`: for agents, persists the `access_token` to - `create-user`: for people and other non-agent accounts. It refuses
`<state>/matrix-token`. Skips registration when the file already an agent's name: `swarm-controller` creates an agent's account and
exists — delete it first to force re-registration. stores its token where the agent's daemon reads it.
- `sync-admin`: ensures this hive's appservice sender account - `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists (`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the (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 always named `main` and is always the primary; the matrix module
declares it for you as an ordinary entry of this map, from declares it for you as an ordinary entry of this map, from
`services.hyperhive.agent.matrix.url` + agent state, so what you add `services.hyperhive.agent.matrix.url` + agent state, so what you add
here are the *further* accounts. hive-c0re pins its `tokenFile` to here are the *further* accounts. Its token comes from the swarm secret
`<state>/matrix-token` and provisions it there, and the store (`swarm-controller` mints it); its `tokenFile` stays pinned to
dashboard's link-account route refuses to create an account by that `<state>/matrix-token` as the fallback, and the dashboard's link-account
name. route refuses to create an account by that name.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
`main` is present exactly when that URL is non-null, which is the whole `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 `{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
to the stable URL every turn instead of respawning a stdio child. to the stable URL every turn instead of respawning a stdio child.
Silently exits when `<state>/matrix-token` is absent (account not yet Silently exits when the primary account has no token yet; the
provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts `systemd.paths.hive-matrix-daemon` watcher restarts it when a token file
it the moment hive-c0re provisions the token. 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 Incoming room events wake the agent via `AgentRequest::Wake` with
`from: "matrix"`. The wake body format depends on the unread state: `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 /// The post-`Swap` bookkeeping tail, split into its own node for dashboard
/// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here /// 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 /// build (build-slot-exempt); the agent lease taken at `Swap` is still held
/// (the whole chain up to `Reconcile` is one agent's subgraph). /// (the whole chain up to `Reconcile` is one agent's subgraph).
async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result<()> { 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 // 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` // `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
// add a contradictory !ok would double-report the same rebuild. // add a contradictory !ok would double-report the same rebuild.
// Full forge + matrix sync on every successful rebuild so the rebuild // Full forge sync on every successful rebuild so the rebuild path is
// path is equivalent to the startup sweep: tokens, config-repo mirror, // equivalent to the startup sweep: tokens, config-repo mirror, meta
// meta access all recover without a hive-c0re restart. // 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::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" // 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 → // hint; rescan so dashboards drop the "needs update" chip; lock bump →
// meta-inputs re-render. // meta-inputs re-render.

View file

@ -20,8 +20,6 @@ use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use reqwest::StatusCode; use reqwest::StatusCode;
use crate::coordinator::Coordinator;
/// Client-server API base this daemon provisions against, from /// Client-server API base this daemon provisions against, from
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from /// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
/// `services.hyperhive.swarm.matrix.apiUrl`). /// `services.hyperhive.swarm.matrix.apiUrl`).
@ -126,45 +124,15 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token() crate::paths::matrix_sender_token()
} }
/// Token file inside the agent's bind-mounted state dir (visible as /// Password file for a matrix account this hive holds a password for: its own
/// `/state/matrix-token` from inside the container). /// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
fn token_path(name: &hive_types::Ident) -> PathBuf { /// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
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` /// 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 { fn password_path(name: &str) -> PathBuf {
crate::paths::matrix_creds_dir().join(format!("{name}-password")) 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. /// Host path where the hive Matrix Space room ID is persisted.
/// Outside every purgeable path — not deleted by `destroy --purge`. /// Outside every purgeable path — not deleted by `destroy --purge`.
#[must_use] #[must_use]
@ -319,8 +287,8 @@ async fn register_user(
"type": "m.login.application_service", "type": "m.login.application_service",
"username": localpart, "username": localpart,
"password": password, "password": password,
// device_id stays stable across re-runs of ensure_user_for so // device_id stays stable across re-runs so a re-mint doesn't
// a re-mint doesn't strand orphan devices in tuwunel. // strand orphan devices in tuwunel.
"device_id": format!("hyperhive-{agent}"), "device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"), "initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false, "inhibit_login": false,
@ -416,32 +384,6 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json) 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 // 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` /// Register a matrix account for `name` with the supplied `password`
/// and return the freshly-minted access token. Unlike [`ensure_user_for`], /// and return the freshly-minted access token. The token is **not**
/// the token is **not** persisted to disk — the caller is responsible /// persisted to disk — the caller is responsible for storing it. Used by
/// for storing it. Used by `hivectl matrix create-user` for human /// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real
/// (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 /// password so the operator can `m.login.password` into matrix web
/// clients afterwards; for headless agent re-provisioning the caller /// clients afterwards; without one the caller passes
/// can pass [`random_password`] to keep the existing throwaway /// [`random_password`].
/// behaviour.
/// ///
/// **Not idempotent**: the matrix `/register` endpoint returns /// **Not idempotent**: the matrix `/register` endpoint returns
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart, /// `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 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 /// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`]. /// 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 /// 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 /// `@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 /// it to the non-purgeable creds path.
/// on the next provisioning sweep.
/// ///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin /// 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}")) .ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
} }
/// Sweep every existing container (manager + sub-agents) and ensure /// Make sure the hive's own `@hive-<hive>:` account exists, then provision
/// each has a matrix user + token on the local homeserver. Called at /// the hive Space and chat room and invite every agent container to both.
/// hive-c0re startup, alongside `forge::ensure_all`, and then /// Agents' own accounts are not created here: `swarm-controller` mints them
/// periodically (see the caller in `main.rs`). No-op when the /// 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 /// hive-matrix container isn't running. Per-step failures are logged
/// but don't abort the sweep. /// but don't abort the sweep.
/// ///
@ -1893,9 +1648,7 @@ pub async fn ensure_all() -> bool {
} }
let mut ok = true; let mut ok = true;
// Loud and non-destructive: with no appservice token this sweep can // Loud and non-destructive: with no appservice token this sweep can
// create nothing, so it does nothing. Agents that already hold a // create nothing, so it does nothing.
// token keep using it — their accounts and sessions are untouched by
// anything in here.
let as_token = match read_appservice_token() { let as_token = match read_appservice_token() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@ -1903,8 +1656,7 @@ pub async fn ensure_all() -> bool {
return false; return false;
} }
}; };
// One HTTP client for the whole sweep — connection pool is // One HTTP client for the whole sweep.
// reused across agents.
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.build() .build()
@ -1932,7 +1684,6 @@ pub async fn ensure_all() -> bool {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue; continue;
}; };
sync_agent(&client, name, &as_token).await;
agent_names.push(name.to_owned()); agent_names.push(name.to_owned());
} }
@ -2101,30 +1852,4 @@ mod tests {
let err = extract_access_token(&body).unwrap_err(); let err = extract_access_token(&body).unwrap_err();
assert!(err.to_string().contains("missing access_token")); 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,36 +539,25 @@ async fn handle_matrix_create_user(
password: Option<&str>, password: Option<&str>,
) -> Result<HostResponse> { ) -> Result<HostResponse> {
require_matrix_present()?; 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 = let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?; crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let mut out = Vec::new(); 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 { let effective_password = match password {
Some(p) => p.to_owned(), Some(p) => p.to_owned(),
None => crate::matrix::random_password().context("generate random matrix password")?, None => crate::matrix::random_password().context("generate random matrix password")?,
}; };
let token = crate::matrix::provision_user_token( let token =
&client, crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
name.as_str(),
&as_token,
&effective_password,
)
.await .await
.with_context(|| format!("matrix create-user {name}"))?; .with_context(|| format!("matrix create-user {name}"))?;
out.push(format!( out.push(format!(
@ -576,15 +565,12 @@ async fn handle_matrix_create_user(
)); ));
out.push(format!("token: {token}")); out.push(format!("token: {token}"));
if password.is_some() { if password.is_some() {
out.push( out.push("password: set as supplied — use it to log into a matrix web client".to_owned());
"password: set as supplied — use it to log into a matrix web client".to_owned(),
);
} else { } else {
out.push( out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
); );
} }
}
Ok(HostResponse::messages(out)) Ok(HostResponse::messages(out))
} }

View file

@ -21,6 +21,7 @@ rmcp.workspace = true
schemars.workspace = true schemars.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
swarm-secret-client.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -1,13 +1,15 @@
//! matrix-sdk `Client` setup for the daemon: read the per-agent access //! matrix-sdk `Client` setup for the daemon: take the per-agent access
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for` //! token [`crate::credential`] resolved, probe `whoami` to recover the
//! wrote, probe `whoami` to recover the agent's matrix `user_id` + //! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
//! `device_id`, restore the matrix-sdk session, return the Client ready //! session, return the Client ready to start sync.
//! 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 //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted //! ccc.de connection): for the swarm's tuwunel the swarm already minted the
//! the token + user/device as the hive's appservice and handed us the //! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
//! `MatrixSession` skips the login flow entirely. //! `MatrixSession` skips the login flow entirely.
//! //!
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
@ -34,10 +36,12 @@ use matrix_sdk::{
use serde::Deserialize; use serde::Deserialize;
use tokio::fs; use tokio::fs;
use crate::credential::Token;
/// Sentinel returned when `build_and_restore` detects that the token is /// Sentinel returned when `build_and_restore` detects that the token is
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
/// removed from disk. Callers should NOT retry — the account needs /// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
/// re-provisioning by hive-c0re. /// account needs re-provisioning.
/// ///
/// Distinct from the general `anyhow::Error` path so callers can use /// Distinct from the general `anyhow::Error` path so callers can use
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry /// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
@ -64,63 +68,53 @@ struct WhoamiResponse {
} }
/// Build + restore a matrix-sdk `Client` for the per-agent bearer /// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// token at `token_file`. The Client points at `homeserver`, persists /// `token`. The Client points at `homeserver`, persists its sqlite cache
/// its sqlite cache under `state_dir`, and is ready for sync once /// under `state_dir`, and is ready for sync once returned.
/// returned.
/// ///
/// Steps: /// Steps:
/// 1. Read the bearer token from `token_file` (trim trailing whitespace). /// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// the bearer — this gives us back the matrix `user_id` + /// the bearer — this gives us back the matrix `user_id` +
/// `device_id` (the registration response had them but hive-c0re /// `device_id` (the registration response had them but only the
/// only persisted the token; whoami is the cheapest recovery path /// token was kept; whoami is the cheapest recovery path
/// and avoids matrix-sdk's circular requirement of needing a /// and avoids matrix-sdk's circular requirement of needing a
/// session to call whoami). /// 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`. /// using a synthetic `MatrixSession`.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the token file is missing or empty, if the /// Returns an error if the `whoami` request fails, or if the matrix-sdk
/// `whoami` request fails, or if the matrix-sdk client fails to build /// client fails to build or restore the session.
/// or restore the session.
pub async fn build_and_restore( pub async fn build_and_restore(
homeserver: &str, homeserver: &str,
token_file: &Path, token: &Token,
state_dir: &Path, state_dir: &Path,
is_primary: bool, is_primary: bool,
) -> Result<Client> { ) -> Result<Client> {
let token = fs::read_to_string(token_file) let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
.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, Ok(ids) => ids,
Err(e) => { Err(e) => {
let msg = format!("{e:#}"); let msg = format!("{e:#}");
if msg.contains("M_UNKNOWN_TOKEN") { if msg.contains("M_UNKNOWN_TOKEN") {
// Homeserver rejected our token — stale session after a homeserver // Homeserver rejected our token — stale session after a homeserver
// state wipe or token expiry. Delete the stale token file so we // state wipe or token expiry. Discard it so we don't loop on it
// don't loop on it. The rest of the handling depends on whether // (which for a store-held credential is a log line and nothing
// this is the primary (hive-internal) account or a secondary one, // else — see `Token::discard_stale`). The rest of the handling
// because a single bad secondary token must NOT take down the // depends on whether this is the primary (hive-internal) account
// whole daemon (and with it every healthy 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!( tracing::warn!(
path = %token_file.display(), credential = %token.origin(),
is_primary, 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 { if is_primary {
// Primary: also drop the matrix-sdk sqlite state keyed to the // Primary: also drop the matrix-sdk sqlite state keyed to the
// now-invalid session, then exit 0 so hive-c0re's `ensure_all` // now-invalid session, then exit 0; the swarm's backfill
// re-provisions the account and the systemd.paths watcher // re-mints the token and systemd restarts us once it
// restarts us once the fresh token file appears. Exit 0 (not // exists (store re-check timer, or the path watcher). Exit 0 (not
// Err) keeps systemd's Restart=on-failure from looping; the // Err) keeps systemd's Restart=on-failure from looping; the
// call site is before any tasks are spawned so there's // call site is before any tasks are spawned so there's
// nothing to clean up. // nothing to clean up.
@ -133,13 +127,13 @@ pub async fn build_and_restore(
} }
std::process::exit(0); 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 // rather than re-erroring); leave the sdk state in place in case
// the operator re-provisions a fresh token for the same device. // the operator re-provisions a fresh token for the same device.
// Return a PermanentBringUpError so the caller can distinguish // Return a PermanentBringUpError so the caller can distinguish
// "don't retry" from a transient network/DNS failure. // "don't retry" from a transient network/DNS failure.
return Err(anyhow::Error::new(PermanentBringUpError( 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); return Err(e);
@ -159,7 +153,7 @@ pub async fn build_and_restore(
let session = MatrixSession { let session = MatrixSession {
meta: SessionMeta { user_id, device_id }, meta: SessionMeta { user_id, device_id },
tokens: SessionTokens { tokens: SessionTokens {
access_token: token, access_token: token.expose().to_owned(),
refresh_token: None, 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 accounts;
pub mod client; pub mod client;
pub mod credential;
pub mod handlers; pub mod handlers;
pub mod mcp; pub mod mcp;
pub mod paths; pub mod paths;

View file

@ -7,22 +7,24 @@
//! Lifecycle: //! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` — //! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account). //! `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 //! → restore matrix-sdk session (no login flow), install the
//! message-event handler, and spawn its own sync loop. //! message-event handler, and spawn its own sync loop.
//! 3. Serve the MCP tools against an account→Client registry; each tool //! 3. Serve the MCP tools against an account→Client registry; each tool
//! call routes to the account named in its `account` arg (the //! call routes to the account named in its `account` arg (the
//! primary account when omitted). //! primary account when omitted).
//! //!
//! Standalone-degraded boot: the PRIMARY account having no token file → //! Standalone-degraded boot: the PRIMARY account having no token →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re //! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
//! provisions it. A SECONDARY account missing its token is skipped (the //! a token file, the store re-check timer for a stored token). A SECONDARY
//! daemon still serves the others). //! account missing its token is skipped (the daemon still serves the others).
//! //!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in //! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy //! `client::build_and_restore`, and it mirrors the missing-token policy
//! above — a rejected PRIMARY token drops the stale token + sdk state and //! 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 //! is removed and that one account is skipped so the daemon keeps serving
//! the primary and any other healthy account. //! 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::accounts::{AccountCfg, Registry};
use hive_matrix_mcp::client::PermanentBringUpError; 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)] #[derive(Parser)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")] #[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
@ -97,13 +100,13 @@ async fn main() -> Result<()> {
sync_loops.push(sync_loop); sync_loops.push(sync_loop);
} }
// No token yet: for the primary that means the daemon isn't // No token yet: for the primary that means the daemon isn't
// useful — exit 0 like the legacy single-account path so the // useful — exit 0, and systemd restarts us once a token exists
// systemd path-watcher restarts us when the token appears. // (the path-watcher for a file, the re-check timer for the store).
Ok(None) if is_primary => { Ok(None) if is_primary => {
tracing::warn!( tracing::warn!(
account = %cfg.name, account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \ "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(()); return Ok(());
} }
@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry(
None 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 /// Restore one account's client (when its token exists), install its
/// message handler, and build its sync loop. Returns /// message handler, and build its sync loop. Returns
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)` /// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
@ -280,21 +323,17 @@ async fn bring_up_account(
); );
return Ok(None); return Ok(None);
}; };
if !tokio::fs::try_exists(&cfg.token_file) let Some(token) = account_token(cfg).await? else {
.await
.unwrap_or(false)
{
return Ok(None); return Ok(None);
} };
tracing::info!( tracing::info!(
account = %cfg.name, account = %cfg.name,
homeserver, homeserver,
token_file = %cfg.token_file.display(), credential = %token.origin(),
state_dir = %cfg.state_dir.display(), state_dir = %cfg.state_dir.display(),
"bringing up matrix account" "bringing up matrix account"
); );
let client = let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
.await .await
.with_context(|| format!("build matrix client for account {}", cfg.name))?; .with_context(|| format!("build matrix client for account {}", cfg.name))?;
// Best-effort: sync the agent icon to this account's matrix avatar over // Best-effort: sync the agent icon to this account's matrix avatar over

View file

@ -8,8 +8,8 @@ use std::path::PathBuf;
/// Resolve the matrix access-token file path. Override via /// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`, /// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent /// where a hive used to write the `main` account's token (the daemon now reads
/// account provisioning. /// it from the store first; see `main.rs`'s `account_token`).
#[must_use] #[must_use]
pub fn token_file() -> PathBuf { pub fn token_file() -> PathBuf {
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") { 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)] #[derive(Subcommand)]
pub enum MatrixCmd { 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 /// Refuses an agent's name: its account comes from the swarm
/// human/other account, prints the access token to stdout. Set a /// (`swarm-controller` creates it and stores its token where the agent
/// password to enable matrix web-client login (otherwise it uses a /// reads it). Set a password to enable matrix web-client login
/// random throwaway). /// (otherwise it uses a random throwaway).
CreateUser { CreateUser {
/// Matrix localpart. For agents: the container/agent name. /// Matrix localpart of a non-agent account — `mara`, `damocles`, etc.
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
name: String, name: String,
/// Set the account password to this string instead of a random /// Set the account password to this string instead of a random
/// throwaway. Use this for operator accounts that need to log /// throwaway. Use this for operator accounts that need to log

View file

@ -14,12 +14,21 @@
}: }:
let let
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
# This agent's own state dir, where hive-c0re provisions the # This agent's own state dir, where a hive used to write the `main`
# hive-internal account's token (`matrix-token`) and the daemon keeps # account's token (`matrix-token`, now the store's fallback) and the
# its matrix-sdk store. Shared by the `main` account entry below and # daemon keeps its matrix-sdk store. Shared by the `main` account entry
# the path-watcher glob at the bottom of this file. # below and the path-watcher glob at the bottom of this file.
stateDir = "/agents/${userName}/state"; stateDir = "/agents/${userName}/state";
accounts = config.services.hyperhive.agent.matrixAccounts; 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 # **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 # 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. # 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 Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern file gets populated is the provisioner's concern
(hive-c0re for the hive-internal `main` account, an (for `main`, a file only when the store has no token: the
operator-supplied secret for an external one). The daemon 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 skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is. exits cleanly to wait on the path watcher when `main`'s is.
''; '';
@ -143,7 +153,7 @@ in
homeserver (`homeserver`, else homeserver (`homeserver`, else
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an `services.hyperhive.agent.matrix.url`). The daemon auto-skips an
account whose URL or token file is missing, and a `systemd.paths` 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`). (same path-trigger shape as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm, - exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms, 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 like any other, so it shows up in the account list --- what you
add here are the *further* accounts (e.g. an external add here are the *further* accounts (e.g. an external
public-matrix account). Its `tokenFile` stays pinned to public-matrix account). Its `tokenFile` stays pinned to
`<state>/matrix-token` (an assertion; that is the one path `<state>/matrix-token` (an assertion; the file the daemon falls back
hive-c0re provisions the hive-internal token to), and the to when the store has no `main` token), and the
dashboard's link-account route refuses to create an account named dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner. `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 # `main` is not a forbidden key --- this module declares it
# itself (see the `matrixAccounts.main` definition below), so the # itself (see the `matrixAccounts.main` definition below), so the
# name must be allowed. What stays rejected is retargeting *its # name must be allowed. What stays rejected is retargeting *its
# token file*: hive-c0re writes the hive-internal account's token # token file*: the daemon's file fallback for `main` is
# to `<state>/matrix-token` and nowhere else, so an override there # `<state>/matrix-token` and nothing else, so an override there
# is an account that evaluates fine and then never restores. The # is an account that evaluates fine and then never restores. The
# other two fields are free to override (a `mkDefault` each). # other two fields are free to override (a `mkDefault` each).
# #
@ -227,8 +237,8 @@ in
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token"; assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
message = message =
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay " "services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the " + "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
+ "hive-internal account's token. Declare a separate account instead of " + "main account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere."; + "pointing `main` elsewhere.";
} }
# Token files must land at the `matrix-token*` name the daemon # 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 # so the one it always has belongs in it. `mkDefault` per field so an
# operator can retarget e.g. the homeserver without a # operator can retarget e.g. the homeserver without a
# conflicting-definition error (the token file is pinned by an # 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` # ⚠️ 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 # 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"; HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; 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 # Homeserver URL. hive-c0re writes this option per agent from the
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>` # hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
# by default — agents run in a private # by default — agents run in a private
@ -357,7 +381,8 @@ in
# `on-failure`, not `always`: the daemon deliberately exits 0 # `on-failure`, not `always`: the daemon deliberately exits 0
# (a clean, non-failure exit) when no token is provisioned yet # (a clean, non-failure exit) when no token is provisioned yet
# (see the module doc above) — the `systemd.paths` watcher # (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 # instead of `always` busy-looping every `RestartSec` until
# then. Once a token exists this is no different from # then. Once a token exists this is no different from
# `hive-bash-daemon`'s reasoning (a down window loses the MCP # `hive-bash-daemon`'s reasoning (a down window loses the MCP
@ -367,11 +392,33 @@ in
RestartSec = 5; RestartSec = 5;
User = userName; User = userName;
Group = 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 # Re-start the daemon while it is down, when its token lives in the store.
# provisions it after agent containers come up). Without this # 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 # the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See # would have no backend until next restart. See
# `docs/agent-lifecycle/persistence.md` (same section as above). # `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