diff --git a/Cargo.lock b/Cargo.lock index 097329b3..21b3cd8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "swarm-secret-client", "tokio", "tracing", "tracing-subscriber", @@ -4913,6 +4914,7 @@ dependencies = [ "sha2 0.11.0", "strum", "swarm-authelia-bridge-sock", + "swarm-matrix-client", "swarm-queue-client", "swarm-secret-client", "time", @@ -4935,14 +4937,23 @@ dependencies = [ "swarm-queue-client", ] +[[package]] +name = "swarm-matrix-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", +] + [[package]] name = "swarm-matrix-ctl" version = "0.1.0" dependencies = [ "anyhow", "clap", - "reqwest", "serde_json", + "swarm-matrix-client", "swarm-secret-client", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index edae4e54..d01321e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", + "swarm-matrix-client", "swarm-matrix-ctl", "swarm-nats-auth", "swarm-queue-client", @@ -101,6 +102,7 @@ hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } +swarm-matrix-client = { path = "swarm-matrix-client" } swarm-queue-client = { path = "swarm-queue-client" } swarm-secret-client = { path = "swarm-secret-client" } thiserror = "2" diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index da4e4db5..6f697e73 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -569,8 +569,9 @@ so on a real hive (where hive-c0re renders that URL per agent) every agent has one; a `null` URL with no operator-declared account is the "this agent has no matrix" state. -**First-boot ordering**: hive-c0re provisions the matrix token AFTER -agent containers come up. Without the path-trigger sibling +**First-boot ordering**: a token can arrive after the container comes +up — a file the hive delivers, or a token the swarm mints into the store. +Without the path-trigger sibling (`systemd.paths.hive-matrix-daemon`, `PathExistsGlob = /matrix-token*` — the trailing `*` also catches a secondary multi-account token like `matrix-token-ccc`), the daemon @@ -578,7 +579,9 @@ would exit 0 quietly the first time it ran and the MCP would have no daemon until the next restart. The `.path` unit makes the appearance of the token re-fire the service so the daemon comes alive in the same boot cycle as -provisioning. The same token watcher also drives avatar setting: on a +provisioning. A token in the store changes no file, so a store-backed agent +also gets a timer that restarts the daemon five minutes after it last +exited. The same token watcher also drives avatar setting: on a restart the daemon re-runs each account's bring-up, which sets the avatar (see below). diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 7f0d0b4b..28457c02 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -44,7 +44,8 @@ hivectl agent ruth rebuild You don't need to do anything else. The controller's next pass creates ruth's forge user and mints her token, and her container fetches it within -about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for +about ten minutes. The same identity puts her in the matrix pass too (step +6). `swarmctl agent mint-forge-token ruth` skips the wait for the pass. A hive without a swarm secret store has no path to a forge token for ruth at all. @@ -237,10 +238,6 @@ control: [`swarm/ui.md`](../swarm/ui.md). # Ensure the appservice's sender account exists first hivectl matrix sync-admin -# Provision ruth's own matrix account — same bootstrap-bypass reasoning -# as forge above, still a required manual step. -hivectl matrix create-user ruth - # Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' @@ -249,6 +246,12 @@ hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' hivectl matrix promote-user mara ``` +ruth's own matrix account comes from the swarm, like every agent's: +`swarm-controller` creates it within five minutes of her holding a store +identity (step 1), and her matrix daemon reads its token from the store. +Without that identity she has no matrix account, and this hive no longer +creates one. + Swarm SSO creates the human operator's own matrix account instead of a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 992de996..2b3a34a8 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -129,31 +129,52 @@ a token. the bind-mount path. It reads only `.yaml`/`.yml` entries from there, so the sibling credentials are invisible to it. The `.yaml` suffix on the credential id is what makes this work. -5. **hive-c0re** reads the appservice token and creates each account with - one `POST /register` typed `m.login.application_service`, persisting - the returned `access_token` to `/matrix-token`. It never - mints the token itself: the value has to be the one the rendered - registration names, and only the nix side writes that. -6. **An account that exists but has lost its token file** is re-tokened - by an appservice `POST /login` — no password and no admin rights - involved. A stored-password login and an admin-room password reset - remain behind that, for accounts created before the appservice existed - or named outside its namespace. -7. **hive-c0re restarts `hive-matrix-daemon`** for the agent - immediately after writing the token so the daemon picks up the - new credential without waiting for a full container restart. If - the restart fails (for example daemon not yet running on first boot) - hive-c0re logs the error as a warning and the `.path`-trigger sibling - (`hive-matrix-daemon.path` watching for `matrix-token` appearance) - brings the daemon up on the same boot cycle anyway. +5. **hive-c0re** reads the appservice token and creates its own + `@hive-:` account, and the accounts an operator asks for with + `hivectl matrix create-user`. It never mints the token itself: the value + has to be the one the rendered registration names, and only the nix + side writes that. +6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each + one with the swarm's own appservice token (next section), stores its + token at `swarm/agents//matrix/main`, and the agent's + `hive-matrix-daemon` reads it from there as the agent itself. + +### The swarm's appservice, and its admin sender + +A second registration sits beside the hive's: id `swarm`, sender `@swarm`, +the same non-exclusive namespace. It's the swarm's identity on the +homeserver, and `swarm-controller` creates every agent's account with it. +tuwunel loads every `.yaml` in `appservice_dir` and refuses only a +duplicate `id` or `as_token`, so the two overlapping namespaces coexist. + +`swarm-matrix-ctl appservice render` mints its tokens inside the +`hive-matrix` container, before tuwunel starts, and renders the +registration tuwunel loads as a second credential. `appservice publish` +then writes its `as_token` to +`swarm/controller/swarm-controller/matrix/appservice-token`. No hive's +policy reaches that path: only matrix-ctl (which writes it) and +`swarm-controller` (which reads it) hold a grant to it. + +`@swarm` is the **one** account the homeserver promotes to admin at boot +(`admin_execute`). Only those two principals read its token, which is +the difference from the hive sender below: every hive reads its own +sender's token. `swarm` is a reserved name, so nothing can create an agent +as this account. + +`swarm-controller` mints each agent's account with the device id +`hyperhive-`, and a login on that device replaces its token. The controller therefore reads +the stored token back with `whoami` every five minutes and re-mints only +when it's missing, unknown to the homeserver, or someone else's. An +agent's daemon picks a re-minted token up on its next start, and a timer +restarts it while it's down. ### The appservice's sender account, and why it isn't an admin `@hive-:` is the appservice's own `sender_localpart`, which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts -anything. It's an **ordinary account**: nothing promotes it, and the -homeserver runs no `admin_execute` for it. +anything. It's an **ordinary account**: nothing promotes it; the +homeserver's `admin_execute` promotes only `@swarm`. **One account per hive.** The localpart carries the hive's name, so a swarm whose hives share a homeserver gives each of them its own identity: the @@ -185,10 +206,8 @@ when its sender is already an admin. They're swarm-level operations, rehomed to the swarm tier rather than granted here; from the hive, `@hive-:` has no admin sender to make that call with, so both get the admin room's refusal rather than an over-privileged credential that -every other call site would also carry. The one hive-side path that -depends on them is the automatic password recovery for an agent that -has lost its stored password — the admin-sender limitation doesn't -touch the ordinary appservice re-login above. +every other call site would also carry. The swarm's own sender is the +admin they need; moving them there is separate work.
Upgrading a hive that shared one sender account with every other hive @@ -239,9 +258,6 @@ restarts, so the first boot after the switch already has both halves. device that minted it; removing the registration token touches no device, no account and no session. `login_with_password` stays on, so the password fallback is still there too. -- **The per-agent sweep honours existing token files.** It skips any - agent that already has a `matrix-token`, so it re-registers no account - and displaces no session. - **The sender account may already be an admin** on such a hive (it won the first-user grant when the hive was new). Nothing here demotes it; the homeserver no longer promotes it, so a hive built fresh has an diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index f05efba3..4be3c1e3 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,20 +54,35 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//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//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//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm/agents//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//matrix/` | `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//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//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//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +**Two rows share the `matrix/` prefix and have different minters, on +purpose.** An agent's `main` account is on the swarm's own homeserver, and +`swarm-controller` creates it with the swarm's appservice token. Every other +account under that prefix is somewhere else entirely, and an operator hands +the controller a credential for it. The operator-facing route refuses the +name `main` for exactly this reason: two writers, one name, and that refusal +is what keeps them apart. + +**The swarm's appservice token sits under `controller/`, the one kind no +hive's policy reads.** Its sender is the homeserver's admin. Under +`agents/`, `hives/` or `services/` every hive could read it. matrix-ctl may +write and read that one leaf; the controller may only read it. + **An agent's mTLS leaf is in the store; a hive's isn't, and the difference isn't an inconsistency.** The rule the exception protects is that nothing can fetch from the store the credential it would need in order to fetch. A diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 06ce86e9..7ec4afa4 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -142,7 +142,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ###### **Subcommands:** -* `create-user` — Create or refresh the matrix account + access token for `` +* `create-user` — Create a matrix account for a person or other non-agent `` and print its access token to stdout * `sync-admin` — Provision (or re-provision) the matrix appservice's sender account * `promote-user` — Promote a matrix user to homeserver admin * `reset-password` — Reset a matrix user's password via the admin API @@ -152,15 +152,15 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ## `hivectl matrix create-user` -Create or refresh the matrix account + access token for ``. +Create a matrix account for a person or other non-agent `` 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] ` ###### **Arguments:** -* `` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc +* `` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc ###### **Options:** diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 358ed60a..3cb96b87 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow running (`services.hyperhive.deploy.matrix.enable = true`). ```bash -hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara --password hunter2 # set a client-login password hivectl matrix sync-admin # provision / refresh the appservice's sender account @@ -60,9 +59,9 @@ hivectl matrix invite mara # invite a user to the hive Space hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias ``` -- `create-user`: for agents, persists the `access_token` to - `/matrix-token`. Skips registration when the file already - exists — delete it first to force re-registration. +- `create-user`: for people and other non-agent accounts. It refuses + an agent's name: `swarm-controller` creates an agent's account and + stores its token where the agent's daemon reads it. - `sync-admin`: ensures this hive's appservice sender account (`@hive-:`, one per hive) exists (the account `hive-c0re` provisions rooms with). Token persisted to the diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index cf5ea334..14f5236d 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -88,10 +88,10 @@ cache), and an optional `homeserver` (defaults to always named `main` and is always the primary; the matrix module declares it for you as an ordinary entry of this map, from `services.hyperhive.agent.matrix.url` + agent state, so what you add -here are the *further* accounts. hive-c0re pins its `tokenFile` to -`/matrix-token` and provisions it there, and the -dashboard's link-account route refuses to create an account by that -name. +here are the *further* accounts. Its token comes from the swarm secret +store (`swarm-controller` mints it); its `tokenFile` stays pinned to +`/matrix-token` as the fallback, and the dashboard's link-account +route refuses to create an account by that name. `main` is present exactly when that URL is non-null, which is the whole @@ -122,9 +122,10 @@ directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort` `{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects to the stable URL every turn instead of respawning a stdio child. -Silently exits when `/matrix-token` is absent (account not yet -provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts -it the moment hive-c0re provisions the token. +Silently exits when the primary account has no token yet; the +`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file +appears, and, on an agent with a store, a timer restarts it while it's +down so it re-reads a token the swarm minted into the store. Incoming room events wake the agent via `AgentRequest::Wake` with `from: "matrix"`. The wake body format depends on the unread state: diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 4d3da7d0..85385c12 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -465,7 +465,7 @@ async fn run_swap(coord: &Arc, name: &str, id: NodeId) -> Result<() /// The post-`Swap` bookkeeping tail, split into its own node for dashboard /// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here -/// means the profile swap succeeded. Store/forge/matrix work only — no nix +/// means the profile swap succeeded. Store/forge work only — no nix /// build (build-slot-exempt); the agent lease taken at `Swap` is still held /// (the whole chain up to `Reconcile` is one agent's subgraph). async fn run_rebuild_bookkeeping(coord: &Arc, name: &str) -> Result<()> { @@ -477,11 +477,11 @@ async fn run_rebuild_bookkeeping(coord: &Arc, name: &str) -> Result // The `Rebuilt` manager event is emitted exactly once per agent by the DAG's // `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile` // add a contradictory !ok would double-report the same rebuild. - // Full forge + matrix sync on every successful rebuild so the rebuild - // path is equivalent to the startup sweep: tokens, config-repo mirror, - // meta access all recover without a hive-c0re restart. + // Full forge sync on every successful rebuild so the rebuild path is + // equivalent to the startup sweep: tokens, config-repo mirror, meta + // access all recover without a hive-c0re restart. (No matrix step: the + // swarm mints an agent's matrix account, not this hive.) crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await; - crate::matrix::sync_agent_standalone(name).await; // Wake the agent on its next turn so claude sees a "you were rebuilt" // hint; rescan so dashboards drop the "needs update" chip; lock bump → // meta-inputs re-render. diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 59f222ab..4440c4d1 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -20,8 +20,6 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use reqwest::StatusCode; -use crate::coordinator::Coordinator; - /// Client-server API base this daemon provisions against, from /// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from /// `services.hyperhive.swarm.matrix.apiUrl`). @@ -126,45 +124,15 @@ pub fn sender_token_path() -> PathBuf { crate::paths::matrix_sender_token() } -/// Token file inside the agent's bind-mounted state dir (visible as -/// `/state/matrix-token` from inside the container). -fn token_path(name: &hive_types::Ident) -> PathBuf { - Coordinator::agent_notes_dir(name).join("matrix-token") -} - -/// Whether an agent's token file is present: a non-empty regular file. -/// Decided from metadata alone, because hive-priv writes the file 0600 -/// and owned by the agent, so `hive-core` cannot read it but can stat it -/// through the 0755 state dir. A check that reads the file always -/// fails here and makes every sweep re-mint the token. -fn token_file_present(path: &std::path::Path) -> bool { - std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > 0) -} - -/// Password file for the agent's matrix account. Stored OUTSIDE the -/// purgeable `agent_state_root` tree so it survives `destroy --purge` -/// and allows re-login recovery when the same agent name is re-spawned. +/// Password file for a matrix account this hive holds a password for: its own +/// `@hive-:` account, or one reset through the admin room. Stored OUTSIDE +/// the purgeable `agent_state_root` tree so it survives `destroy --purge`. /// /// Path: `/var/lib/hyperhive/matrix/creds/-password` -/// -/// The token file lives inside the agent's bind-mounted state dir (under -/// `agent_notes_dir`) so the agent container can read it; the password -/// file is host-side only (agents never log in by password — they use -/// the access token exclusively) and belongs with other hive-c0re -/// credential state, not inside the purgeable per-agent tree. fn password_path(name: &str) -> PathBuf { crate::paths::matrix_creds_dir().join(format!("{name}-password")) } -/// Legacy password path (inside the old purgeable `agent_notes_dir`). -/// Used only during the one-time migration in [`ensure_user_for`] to -/// move credentials from old deployments to the new location. Safe to -/// call after `destroy --purge` — the path will simply not exist and -/// the migration is a no-op. -fn legacy_password_path(name: &hive_types::Ident) -> PathBuf { - Coordinator::agent_notes_dir(name).join("matrix-password") -} - /// Host path where the hive Matrix Space room ID is persisted. /// Outside every purgeable path — not deleted by `destroy --purge`. #[must_use] @@ -319,8 +287,8 @@ async fn register_user( "type": "m.login.application_service", "username": localpart, "password": password, - // device_id stays stable across re-runs of ensure_user_for so - // a re-mint doesn't strand orphan devices in tuwunel. + // device_id stays stable across re-runs so a re-mint doesn't + // strand orphan devices in tuwunel. "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, @@ -416,32 +384,6 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re extract_access_token(&json) } -/// Auto-recovery helper: reset a user's matrix password through the admin -/// room when the locally stored password is missing. Returns the new -/// password (already persisted to [`password_path`]) on success. -/// -/// ⚠️ Needs an **admin sender**, which `@hive-:` 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 { - let sender_token = read_sender_token() - .context("matrix: the matrix sender token is unavailable for auto-recovery")?; - let server_name = discover_server_name(client) - .await - .context("matrix: discover_server_name for auto-recovery")?; - let effective_password = reset_user_password(client, &sender_token, name, &server_name) - .await - .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; - tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); - Ok(effective_password) -} - // --------------------------------------------------------------------------- // Admin-room fallback for password reset // --------------------------------------------------------------------------- @@ -711,162 +653,13 @@ async fn admin_room_reset_password( }) } -/// Ensure `name` has a matrix user + token file on the local -/// homeserver. Skips provisioning entirely if the token file already -/// exists (treating a present token as proof the account is good). -/// To force re-provisioning, delete the token file. -/// -/// When registration fails with `M_USER_IN_USE` (account exists in the -/// homeserver but the token file was deleted) this falls back to -/// `m.login.password` using the persisted `matrix-password` file. If -/// that file is also missing, recovery requires manual intervention: -/// `hivectl matrix create-user --password `. -/// -/// `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-:` is not. - tracing::info!( - %name, - "matrix: stored password missing, attempting admin-room auto-recovery" - ); - match auto_reset_password(client, name).await { - Ok(new_pw) => new_pw, - Err(e) => { - anyhow::bail!( - "matrix: user {name} already exists but password is missing \ - and admin auto-recovery failed ({e:#}) — run:\n\ - hivectl matrix reset-password {name}\n\ - hivectl matrix create-user {name}" - ) - } - } - }; - login_user(client, name, &stored).await.with_context(|| { - format!( - "matrix: login fallback for {name} failed — if homeserver was wiped, delete \ - the matrix-password file and retry" - ) - })? - } - Err(other) => return Err(other), - }; - - finish_user_provisioning(name, &access_token).await -} - -/// Persist a freshly-obtained agent access token and kick the agent's -/// matrix daemon. Shared by every way [`ensure_user_for`] can end up -/// holding a token — creation, appservice login, password login — so a -/// new recovery path cannot forget half of it. -async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> { - // Write the token via hive-priv (root helper): hive-c0re runs as the - // unprivileged `hive-core` user and cannot write to agent-owned state - // directories directly. hive-priv writes the file 0600 and chowns it - // to the agent user so it is readable from inside the container. - crate::priv_client::write_agent_matrix_token(name, access_token, None, None) - .await - .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; - tracing::info!(%name, "matrix: provisioned access token"); - - // Kick the daemon so it picks up the new token without waiting for a - // full container restart — see docs/integrations/matrix.md::Provisioning flow. - if let Err(e) = crate::priv_client::restart_matrix_daemon(name).await { - tracing::warn!(%name, error = ?e, "matrix: could not restart hive-matrix-daemon (token written; daemon will reload on next container start)"); - } else { - tracing::info!(%name, "matrix: restarted hive-matrix-daemon to pick up new token"); - } - - Ok(()) -} - /// Register a matrix account for `name` with the supplied `password` -/// and return the freshly-minted access token. Unlike [`ensure_user_for`], -/// the token is **not** persisted to disk — the caller is responsible -/// for storing it. Used by `hivectl matrix create-user` for human -/// (non-agent) accounts so we don't create stray -/// `/var/lib/hyperhive/agents//` directories for users that -/// aren't agents. For operator accounts the caller passes a real +/// and return the freshly-minted access token. The token is **not** +/// persisted to disk — the caller is responsible for storing it. Used by +/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real /// password so the operator can `m.login.password` into matrix web -/// clients afterwards; for headless agent re-provisioning the caller -/// can pass [`random_password`] to keep the existing throwaway -/// behaviour. +/// clients afterwards; without one the caller passes +/// [`random_password`]. /// /// **Not idempotent**: the matrix `/register` endpoint returns /// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart, @@ -882,44 +675,6 @@ pub async fn provision_user_token( register_user(client, name, as_token, password).await } -/// Per-agent matrix sync: ensure the agent has a matrix account + token. -/// All operations are idempotent; failures are logged as warnings but -/// don't abort the caller. -pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) { - if let Err(e) = ensure_user_for(client, name, as_token).await { - tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); - } -} - -/// Standalone per-agent sync that handles its own setup: checks if -/// hive-matrix is present, reads the appservice token, and builds -/// an HTTP client before delegating to [`sync_agent`]. Mirrors the -/// setup in [`ensure_all`] so the rebuild path and the startup sweep -/// stay equivalent. No-op when the matrix container is absent. -pub async fn sync_agent_standalone(name: &str) { - if !is_present() { - return; - } - let as_token = match read_appservice_token() { - Ok(t) => t, - Err(e) => { - tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed"); - return; - } - }; - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() - { - Ok(c) => c, - Err(e) => { - tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed"); - return; - } - }; - sync_agent(&client, name, &as_token).await; -} - /// Ensure the hive's `@hive-:` matrix user exists and that its access token is /// persisted at [`sender_token_path()`]. /// @@ -1179,8 +934,7 @@ pub async fn promote_user_to_admin( /// /// Sends `!admin users reset-password @:` to the admin room as /// `@hive-:`, polls for the bot's response containing the new password, and persists -/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login -/// on the next provisioning sweep. +/// it to the non-purgeable creds path. /// /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and /// the same consequence: `@hive-:` is an ordinary account with no admin @@ -1874,10 +1628,11 @@ async fn resolve_room_alias( .ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}")) } -/// Sweep every existing container (manager + sub-agents) and ensure -/// each has a matrix user + token on the local homeserver. Called at -/// hive-c0re startup, alongside `forge::ensure_all`, and then -/// periodically (see the caller in `main.rs`). No-op when the +/// Make sure the hive's own `@hive-:` account exists, then provision +/// the hive Space and chat room and invite every agent container to both. +/// Agents' own accounts are not created here: `swarm-controller` mints them +/// with the swarm's appservice token. Called at hive-c0re startup, alongside +/// `forge::ensure_all`, and then periodically (see the caller in `main.rs`). No-op when the /// hive-matrix container isn't running. Per-step failures are logged /// but don't abort the sweep. /// @@ -1893,9 +1648,7 @@ pub async fn ensure_all() -> bool { } let mut ok = true; // Loud and non-destructive: with no appservice token this sweep can - // create nothing, so it does nothing. Agents that already hold a - // token keep using it — their accounts and sessions are untouched by - // anything in here. + // create nothing, so it does nothing. let as_token = match read_appservice_token() { Ok(t) => t, Err(e) => { @@ -1903,8 +1656,7 @@ pub async fn ensure_all() -> bool { return false; } }; - // One HTTP client for the whole sweep — connection pool is - // reused across agents. + // One HTTP client for the whole sweep. let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() @@ -1932,7 +1684,6 @@ pub async fn ensure_all() -> bool { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; - sync_agent(&client, name, &as_token).await; agent_names.push(name.to_owned()); } @@ -2101,30 +1852,4 @@ mod tests { let err = extract_access_token(&body).unwrap_err(); assert!(err.to_string().contains("missing access_token")); } - - #[test] - fn token_file_present_only_for_a_non_empty_regular_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let token = dir.path().join("matrix-token"); - assert!(!token_file_present(&token), "missing file"); - std::fs::write(&token, "").unwrap(); - assert!(!token_file_present(&token), "empty file"); - std::fs::write(&token, "tok\n").unwrap(); - assert!(token_file_present(&token), "non-empty file"); - assert!(!token_file_present(dir.path()), "directory"); - } - - /// The sweep runs as `hive-core`, which cannot read the agent-owned - /// 0600 token file. `chmod 000` does not stop root, so this test uses - /// content that `read_to_string` rejects instead: the predicate must - /// still report the file as present, which holds only if it never - /// reads the content. - #[test] - fn token_file_present_does_not_read_the_content() { - let dir = tempfile::tempdir().expect("tempdir"); - let token = dir.path().join("matrix-token"); - std::fs::write(&token, [0xff, 0xfe]).unwrap(); - assert!(std::fs::read_to_string(&token).is_err()); - assert!(token_file_present(&token)); - } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 8b1ae675..411b616d 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -539,51 +539,37 @@ async fn handle_matrix_create_user( password: Option<&str>, ) -> Result { require_matrix_present()?; + if agent_exists(name)? { + // The swarm mints an agent's account and stores its token where the + // agent reads it; a second minter here would replace that token on the + // same device. + anyhow::bail!( + "matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \ + the swarm: swarm-controller creates it and re-checks it every five minutes" + ); + } let as_token = crate::matrix::read_appservice_token().context("read matrix appservice token")?; let client = matrix_http_client()?; let mut out = Vec::new(); - if agent_exists(name)? { - if password.is_some() { - // Agents auth by access_token, never by password — the - // boot-sweep provisioning path doesn't accept one. Refuse - // rather than silently dropping it. - anyhow::bail!( - "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" - ); - } - crate::matrix::ensure_user_for(&client, name.as_str(), &as_token) + let effective_password = match password { + Some(p) => p.to_owned(), + None => crate::matrix::random_password().context("generate random matrix password")?, + }; + let token = + crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password) .await .with_context(|| format!("matrix create-user {name}"))?; - let path = Coordinator::agent_notes_dir(name).join("matrix-token"); - out.push(format!("matrix: provisioned agent user '{name}'")); - out.push(format!("token persisted at: {}", path.display())); + out.push(format!( + "matrix: provisioned user '{name}' (not an agent — token not persisted)" + )); + out.push(format!("token: {token}")); + if password.is_some() { + out.push("password: set as supplied — use it to log into a matrix web client".to_owned()); } else { - let effective_password = match password { - Some(p) => p.to_owned(), - None => crate::matrix::random_password().context("generate random matrix password")?, - }; - let token = crate::matrix::provision_user_token( - &client, - name.as_str(), - &as_token, - &effective_password, - ) - .await - .with_context(|| format!("matrix create-user {name}"))?; - out.push(format!( - "matrix: provisioned user '{name}' (not an agent — token not persisted)" - )); - out.push(format!("token: {token}")); - if password.is_some() { - out.push( - "password: set as supplied — use it to log into a matrix web client".to_owned(), - ); - } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); - } + out.push( + "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), + ); } Ok(HostResponse::messages(out)) } diff --git a/hive-matrix-mcp/Cargo.toml b/hive-matrix-mcp/Cargo.toml index 5b83916d..1693d81a 100644 --- a/hive-matrix-mcp/Cargo.toml +++ b/hive-matrix-mcp/Cargo.toml @@ -21,6 +21,7 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +swarm-secret-client.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index f77619fe..86eaf7c7 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -1,13 +1,15 @@ -//! matrix-sdk `Client` setup for the daemon: read the per-agent access -//! token from the state-dir file `hive-c0re::matrix::ensure_user_for` -//! wrote, probe `whoami` to recover the agent's matrix `user_id` + -//! `device_id`, restore the matrix-sdk session, return the Client ready -//! to start sync. +//! matrix-sdk `Client` setup for the daemon: take the per-agent access +//! token [`crate::credential`] resolved, probe `whoami` to recover the +//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk +//! session, return the Client ready to start sync. +//! +//! 🩸 The token arrives as a [`Token`] and never as a path this module +//! reads — where it came from is [`crate::credential`]'s question, and the +//! only thing said about it here is its [`crate::credential::Origin`]. //! //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's -//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted -//! the token + user/device as the hive's appservice and handed us the -//! bearer in a file. matrix-sdk's `restore_session` with a constructed +//! ccc.de connection): for the swarm's tuwunel the swarm already minted the +//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed //! `MatrixSession` skips the login flow entirely. //! //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. @@ -34,10 +36,12 @@ use matrix_sdk::{ use serde::Deserialize; use tokio::fs; +use crate::credential::Token; + /// Sentinel returned when `build_and_restore` detects that the token is /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been -/// removed from disk. Callers should NOT retry — the account needs -/// re-provisioning by hive-c0re. +/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the +/// account needs re-provisioning. /// /// Distinct from the general `anyhow::Error` path so callers can use /// `err.downcast_ref::()` to distinguish "retry @@ -64,63 +68,53 @@ struct WhoamiResponse { } /// Build + restore a matrix-sdk `Client` for the per-agent bearer -/// token at `token_file`. The Client points at `homeserver`, persists -/// its sqlite cache under `state_dir`, and is ready for sync once -/// returned. +/// `token`. The Client points at `homeserver`, persists its sqlite cache +/// under `state_dir`, and is ready for sync once returned. /// /// Steps: -/// 1. Read the bearer token from `token_file` (trim trailing whitespace). -/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with +/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with /// the bearer — this gives us back the matrix `user_id` + -/// `device_id` (the registration response had them but hive-c0re -/// only persisted the token; whoami is the cheapest recovery path +/// `device_id` (the registration response had them but only the +/// token was kept; whoami is the cheapest recovery path /// and avoids matrix-sdk's circular requirement of needing a /// session to call whoami). -/// 3. Build the real Client with the sqlite store + `restore_session` +/// 2. Build the real Client with the sqlite store + `restore_session` /// using a synthetic `MatrixSession`. /// /// # Errors /// -/// Returns an error if the token file is missing or empty, if the -/// `whoami` request fails, or if the matrix-sdk client fails to build -/// or restore the session. +/// Returns an error if the `whoami` request fails, or if the matrix-sdk +/// client fails to build or restore the session. pub async fn build_and_restore( homeserver: &str, - token_file: &Path, + token: &Token, state_dir: &Path, is_primary: bool, ) -> Result { - let token = fs::read_to_string(token_file) - .await - .with_context(|| format!("read matrix token from {}", token_file.display()))? - .trim() - .to_owned(); - if token.is_empty() { - return Err(anyhow!("matrix token at {} is empty", token_file.display())); - } - - let (user_id, device_id) = match whoami(homeserver, &token).await { + let (user_id, device_id) = match whoami(homeserver, token.expose()).await { Ok(ids) => ids, Err(e) => { let msg = format!("{e:#}"); if msg.contains("M_UNKNOWN_TOKEN") { // Homeserver rejected our token — stale session after a homeserver - // state wipe or token expiry. Delete the stale token file so we - // don't loop on it. The rest of the handling depends on whether - // this is the primary (hive-internal) account or a secondary one, - // because a single bad secondary token must NOT take down the - // whole daemon (and with it every healthy account). + // state wipe or token expiry. Discard it so we don't loop on it + // (which for a store-held credential is a log line and nothing + // else — see `Token::discard_stale`). The rest of the handling + // depends on whether this is the primary (hive-internal) account + // or a secondary one, because a single bad secondary token must + // NOT take down the whole daemon (and with it every healthy + // account). tracing::warn!( - path = %token_file.display(), + credential = %token.origin(), is_primary, - "matrix token rejected (M_UNKNOWN_TOKEN); removing stale token" + "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" ); - let _ = fs::remove_file(token_file).await; + token.discard_stale().await; if is_primary { // Primary: also drop the matrix-sdk sqlite state keyed to the - // now-invalid session, then exit 0 so hive-c0re's `ensure_all` - // re-provisions the account and the systemd.paths watcher - // restarts us once the fresh token file appears. Exit 0 (not + // now-invalid session, then exit 0; the swarm's backfill + // re-mints the token and systemd restarts us once it + // exists (store re-check timer, or the path watcher). Exit 0 (not // Err) keeps systemd's Restart=on-failure from looping; the // call site is before any tasks are spawned so there's // nothing to clean up. @@ -133,13 +127,13 @@ pub async fn build_and_restore( } std::process::exit(0); } - // Secondary: token removed (so it's cleanly skipped next boot + // Secondary: token discarded (so it's cleanly skipped next boot // rather than re-erroring); leave the sdk state in place in case // the operator re-provisions a fresh token for the same device. // Return a PermanentBringUpError so the caller can distinguish // "don't retry" from a transient network/DNS failure. return Err(anyhow::Error::new(PermanentBringUpError( - "matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(), + "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(), ))); } return Err(e); @@ -159,7 +153,7 @@ pub async fn build_and_restore( let session = MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: SessionTokens { - access_token: token, + access_token: token.expose().to_owned(), refresh_token: None, }, }; diff --git a/hive-matrix-mcp/src/credential.rs b/hive-matrix-mcp/src/credential.rs new file mode 100644 index 00000000..8488ba92 --- /dev/null +++ b/hive-matrix-mcp/src/credential.rs @@ -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//matrix/` 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!("", 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> { + 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> { + 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 { + 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) -> Result> { + 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 { + 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(" Result<()> { sync_loops.push(sync_loop); } // No token yet: for the primary that means the daemon isn't - // useful — exit 0 like the legacy single-account path so the - // systemd path-watcher restarts us when the token appears. + // useful — exit 0, and systemd restarts us once a token exists + // (the path-watcher for a file, the re-check timer for the store). Ok(None) if is_primary => { tracing::warn!( account = %cfg.name, "primary matrix account has no token yet; exiting cleanly \ - (systemd restarts us when hive-c0re provisions it)" + (systemd restarts us once one exists)" ); return Ok(()); } @@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry( None } +/// Resolve `cfg`'s access token, preferring the copy this agent can fetch +/// itself. +/// +/// 🏛️ The store read is the point: the credential at +/// `swarm/agents//matrix/` 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//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> { + if let Some(agent) = credential::agent_name() { + match Token::from_store(&agent, &cfg.name).await { + Ok(Some(token)) => return Ok(Some(token)), + // No store in this deployment: nothing to report, the hive-side + // delivery is the whole mechanism here. + Ok(None) => {} + Err(e) => tracing::warn!( + account = %cfg.name, + error = %format!("{e:#}"), + "could not read this account's credential from the store as this agent; \ + falling back to the token the hive delivered" + ), + } + } + Token::from_file(&cfg.token_file).await +} + /// Restore one account's client (when its token exists), install its /// message handler, and build its sync loop. Returns /// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)` @@ -280,23 +323,19 @@ async fn bring_up_account( ); return Ok(None); }; - if !tokio::fs::try_exists(&cfg.token_file) - .await - .unwrap_or(false) - { + let Some(token) = account_token(cfg).await? else { return Ok(None); - } + }; tracing::info!( account = %cfg.name, homeserver, - token_file = %cfg.token_file.display(), + credential = %token.origin(), state_dir = %cfg.state_dir.display(), "bringing up matrix account" ); - let client = - client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) - .await - .with_context(|| format!("build matrix client for account {}", cfg.name))?; + let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary) + .await + .with_context(|| format!("build matrix client for account {}", cfg.name))?; // Best-effort: sync the agent icon to this account's matrix avatar over // the live (authenticated, correct-homeserver) Client. Replaces the old // curl oneshot; failures are swallowed inside sync_avatar. diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 98257c0f..8847f41f 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -8,8 +8,8 @@ use std::path::PathBuf; /// Resolve the matrix access-token file path. Override via /// `HIVE_MATRIX_TOKEN_FILE`; default is `/matrix-token`, -/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent -/// account provisioning. +/// where a hive used to write the `main` account's token (the daemon now reads +/// it from the store first; see `main.rs`'s `account_token`). #[must_use] pub fn token_file() -> PathBuf { if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") { diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 42c2b6fd..f94da8cf 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -286,15 +286,15 @@ impl From for hive_host_sock::ReconcileDirection { #[derive(Subcommand)] pub enum MatrixCmd { - /// Create or refresh the matrix account + access token for ``. + /// Create a matrix account for a person or other non-agent `` and + /// print its access token to stdout. /// - /// For an existing agent, persists the token to its state dir; for a - /// human/other account, prints the access token to stdout. Set a - /// password to enable matrix web-client login (otherwise it uses a - /// random throwaway). + /// Refuses an agent's name: its account comes from the swarm + /// (`swarm-controller` creates it and stores its token where the agent + /// reads it). Set a password to enable matrix web-client login + /// (otherwise it uses a random throwaway). CreateUser { - /// Matrix localpart. For agents: the container/agent name. - /// For humans: any matrix localpart — `mara`, `damocles`, etc. + /// Matrix localpart of a non-agent account — `mara`, `damocles`, etc. name: String, /// Set the account password to this string instead of a random /// throwaway. Use this for operator accounts that need to log diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 7da62f78..3dc6af81 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -14,12 +14,21 @@ }: let userName = config.services.hyperhive.agent.user.name; - # This agent's own state dir, where hive-c0re provisions the - # hive-internal account's token (`matrix-token`) and the daemon keeps - # its matrix-sdk store. Shared by the `main` account entry below and - # the path-watcher glob at the bottom of this file. + # This agent's own state dir, where a hive used to write the `main` + # account's token (`matrix-token`, now the store's fallback) and the + # daemon keeps its matrix-sdk store. Shared by the `main` account entry + # below and the path-watcher glob at the bottom of this file. stateDir = "/agents/${userName}/state"; accounts = config.services.hyperhive.agent.matrixAccounts; + # This agent's own identity at the swarm secret store (./bao.nix). The daemon + # reads each account's token from the store itself, `main` included (the + # swarm mints that one), so the store's coordinates belong on its unit. The + # same three credential ids ./bao.nix and ./forge-token.nix load. + baoCfg = config.services.hyperhive.agent.bao; + storeConfigured = baoCfg.addr != null; + certCredential = "hive-agent-bao-cert"; + keyCredential = "hive-agent-bao-key"; + serverCaCredential = "hive-agent-bao-server-ca"; # **The enable signal.** Matrix is on for this agent exactly when it has at # least one account, because an account is the only thing the daemon has to # do: no account, no Client, no sync, no tool surface worth injecting. @@ -90,8 +99,9 @@ in Path to this account's bearer-token file. The daemon reads the token from here to restore the matrix session; how the file gets populated is the provisioner's concern - (hive-c0re for the hive-internal `main` account, an - operator-supplied secret for an external one). The daemon + (for `main`, a file only when the store has no token: the + swarm mints `main` into the store; an operator-supplied + secret for an external one). The daemon skips an extra account whose token file is absent, and exits cleanly to wait on the path watcher when `main`'s is. ''; @@ -143,7 +153,7 @@ in homeserver (`homeserver`, else `services.hyperhive.agent.matrix.url`). The daemon auto-skips an account whose URL or token file is missing, and a `systemd.paths` - watcher restarts it the moment hive-c0re provisions the token + watcher restarts it the moment a token file appears (same path-trigger shape as `forge-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, @@ -173,8 +183,8 @@ in like any other, so it shows up in the account list --- what you add here are the *further* accounts (e.g. an external public-matrix account). Its `tokenFile` stays pinned to - `/matrix-token` (an assertion; that is the one path - hive-c0re provisions the hive-internal token to), and the + `/matrix-token` (an assertion; the file the daemon falls back + to when the store has no `main` token), and the dashboard's link-account route refuses to create an account named `main` --- the entry belongs to the module, not to a provisioner. @@ -215,8 +225,8 @@ in # `main` is not a forbidden key --- this module declares it # itself (see the `matrixAccounts.main` definition below), so the # name must be allowed. What stays rejected is retargeting *its - # token file*: hive-c0re writes the hive-internal account's token - # to `/matrix-token` and nowhere else, so an override there + # token file*: the daemon's file fallback for `main` is + # `/matrix-token` and nothing else, so an override there # is an account that evaluates fine and then never restores. The # other two fields are free to override (a `mkDefault` each). # @@ -227,8 +237,8 @@ in assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token"; message = "services.hyperhive.agent.matrixAccounts.main.tokenFile must stay " - + "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the " - + "hive-internal account's token. Declare a separate account instead of " + + "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the " + + "main account's token. Declare a separate account instead of " + "pointing `main` elsewhere."; } # Token files must land at the `matrix-token*` name the daemon @@ -260,7 +270,7 @@ in # so the one it always has belongs in it. `mkDefault` per field so an # operator can retarget e.g. the homeserver without a # conflicting-definition error (the token file is pinned by an - # assertion above, since hive-c0re owns that path). + # assertion above, since the daemon's fallback reads that path). # # ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled` # from being trivially true for every agent in the hive. A `main` with no @@ -309,6 +319,20 @@ in HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; RUST_LOG = "info"; } + # The store's coordinates and which agent this is, so the daemon reads + # `swarm/agents//matrix/` as itself. The NAME and not a + # path: `swarm_secret_client` builds both the path and the cert-auth role + # from it. Paths only — `%d` is this unit's own credentials directory. + // lib.optionalAttrs storeConfigured { + HIVE_AGENT_NAME = userName; + BAO_ADDR = baoCfg.addr; + BAO_CLIENT_CERT = "%d/${certCredential}"; + BAO_CLIENT_KEY = "%d/${keyCredential}"; + # Named even when no CA was delivered: a bare `LoadCredential=` is + # non-fatal when absent, and the daemon treats a missing or empty file + # as "use the container's own trust store". + BAO_CACERT = "%d/${serverCaCredential}"; + } # Homeserver URL. hive-c0re writes this option per agent from the # hive's own gateway URL (`gatewayHost`'s vhost, `chat.` # by default — agents run in a private @@ -357,7 +381,8 @@ in # `on-failure`, not `always`: the daemon deliberately exits 0 # (a clean, non-failure exit) when no token is provisioned yet # (see the module doc above) — the `systemd.paths` watcher - # below re-fires it the moment hive-c0re provisions one, + # below re-fires it the moment a token file appears (the timer + # above, for a token in the store), # instead of `always` busy-looping every `RestartSec` until # then. Once a token exists this is no different from # `hive-bash-daemon`'s reasoning (a down window loses the MCP @@ -367,11 +392,33 @@ in RestartSec = 5; User = userName; Group = userName; + } + # This agent's own certificate, imported by id so systemd materialises it + # under this unit's `User=` — the same bare form ./forge-token.nix uses. + // lib.optionalAttrs storeConfigured { + LoadCredential = [ + certCredential + keyCredential + serverCaCredential + ]; }; }; - # Re-fire the daemon when the matrix token appears (hive-c0re - # provisions it after agent containers come up). Without this + # Re-start the daemon while it is down, when its token lives in the store. + # The path unit below only sees files, and a token the swarm mints or + # replaces in the store changes no file: a daemon that exited on a missing + # or replaced token would otherwise stay down until the container restarts. + # Relative to the daemon's last exit, so it never fires while the daemon + # runs; five minutes is the swarm's own re-mint cadence. + systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) { + description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store"; + wantedBy = [ "timers.target" ]; + timerConfig.OnUnitInactiveSec = "5min"; + }; + + # Re-fire the daemon when the matrix token appears (a token + # file: an extra account the hive delivers, or a `main` from before the + # swarm minted it). Without this # the daemon would exit 0 silently on first boot and the MCP # would have no backend until next restart. See # `docs/agent-lifecycle/persistence.md` (same section as above). diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 0f4fc3cb..276d8728 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -172,6 +172,26 @@ let isReadOnly = true; }); + # ── the swarm's own appservice ────────────────────────────────────── + # + # A second registration beside the hive's `hyperhive` one, and the swarm's + # identity on this homeserver: `swarm-controller` creates every agent's + # account with its token. Minted INSIDE this container by + # `swarm-matrix-ctl appservice render` and published to the store by + # `appservice publish`, which is why it is gated on the same identity as + # the sender mint: with nobody to publish it, a registration here would be + # an admin credential nobody reads. + # + # Its sender is promoted to homeserver admin at boot (`admin_execute` + # below), so its token goes only to a store path no hive's policy reaches. + # `swarm` is in ../reserved-names.nix, so no agent can be created as this + # account. + swarmSenderLocalpart = "swarm"; + # Inside the container: the render unit's `StateDirectory`. `ephemeral = + # false` keeps it across restarts, so "mint when absent" is mint once. + swarmAppserviceDir = "/var/lib/swarm-matrix-appservice"; + swarmAppserviceCredentialId = "swarm-appservice.yaml"; + # Where a reader of the published credential is told the token is good for. # Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an # `Option`, and matrix-ctl reads an empty variable as absent rather than as @@ -1304,21 +1324,27 @@ in # `Services::start()`, before the listener accepts anything. appservice_dir = appserviceCredentialDir; - # No `admin_execute` promotion for `@${hiveLocalpart}`. The - # hive's account is an ordinary user: it creates the hive - # Space and chat room and invites agents into them, all of - # which ride on being the rooms' own creator at power level - # 100, and none of which is a homeserver-admin capability. - # Granting it server admin at boot would hand a credential - # that every hive reads far more than the work needs. - # - # The two operations that do need an admin sender — - # `!admin users make-user-admin` and - # `!admin users reset-password`, both messages into - # `#admins:${effectiveServerName}` — therefore have no - # working sender here. They are swarm-level operations and - # are being rehomed as such; until then they fail, loudly, - # rather than being served by an over-privileged token. + } + # Exactly one account is promoted to homeserver admin at boot: + # the swarm appservice's sender. Its token is read by + # matrix-ctl and swarm-controller only. `@${hiveLocalpart}` + # stays an ordinary user, because every hive reads its + # credential; what it does (the Space, the chat room, the + # invites) rides on being the rooms' creator, not on admin. + # + # The registration load creates the sender inside + # `Services::start()`, and `admin_execute` runs after that and + # before the listener accepts anything. `make-user-admin` is a + # no-op on an account that is already admin. + # `admin_execute_errors_ignore` is load-bearing: a failing + # command aborts startup when it is false. + // lib.optionalAttrs ctlActive { + admin_execute = [ + "users make-user-admin @${swarmSenderLocalpart}:${effectiveServerName}" + ]; + admin_execute_errors_ignore = true; + } + // { # Server-side E2EE is opt-in (default off); the agent matrix # client always supports decryption regardless. allow_encryption = cfg.allowEncryption; @@ -1387,7 +1413,13 @@ in # every OAuth exchange, not just at startup, so it has to # outlive the unit's start — a credentials path does. "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" - ]; + ] + # The swarm registration, a second `.yaml` in the same directory: + # tuwunel loads every one it finds (`appservice/mod.rs`), and + # refuses only a duplicate `id` or `as_token`, never an + # overlapping namespace. A missing source fails this unit, which + # is why the render below is `requiredBy` it and local only. + ++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml"; # Publish the appservice sender account's access token to the swarm # store, once, under an identity that belongs to this container and @@ -1449,6 +1481,61 @@ in }; }; + # The swarm registration, minted and rendered before the homeserver + # loads it. No network and no store: this is on tuwunel's start + # path, and a store outage must not keep the homeserver down. + systemd.services.swarm-matrix-appservice-render = lib.mkIf ctlActive { + description = "render the swarm's appservice registration"; + before = [ "tuwunel.service" ]; + requiredBy = [ "tuwunel.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice render"; + StateDirectory = baseNameOf swarmAppserviceDir; + StateDirectoryMode = "0700"; + UMask = "0077"; + SyslogIdentifier = "swarm-matrix-appservice"; + }; + environment = { + MATRIX_APPSERVICE_DIR = swarmAppserviceDir; + MATRIX_APPSERVICE_SENDER = swarmSenderLocalpart; + # The hive registration's namespace. Agents' localparts are + # bare `[a-z0-9-]`, so nothing narrower covers them without + # also covering people. + MATRIX_APPSERVICE_USER_REGEX = appserviceUserRegex; + }; + }; + + # Hand the swarm registration's token to swarm-controller, through + # the store. Same identity and retry shape as `swarm-matrix-ctl` + # above; the write lands at a path only matrix-ctl and the + # controller are granted (./swarm-bao.nix). + systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive { + description = "publish the swarm's appservice token to the swarm secret store"; + after = [ "swarm-matrix-appservice-render.service" ]; + requires = [ "swarm-matrix-appservice-render.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice publish"; + Restart = "on-failure"; + RestartSec = 30; + TimeoutStartSec = 60; + SyslogIdentifier = "swarm-matrix-appservice"; + }; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile; + BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile; + MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole; + MATRIX_APPSERVICE_DIR = swarmAppserviceDir; + } + // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { + BAO_CACERT = deployCfg.bao.serverCaFile; + }; + }; + environment.systemPackages = [ deployCfg.matrix.package ]; }; }; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index d2fa786e..372c088a 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -194,6 +194,10 @@ let # `read` too: `mint_and_verify` reads a credential back before writing so a # re-run keeps the value a live agent already holds instead of rotating it — # the read is required, not incidental. + # + # The swarm appservice token, read-only: the controller creates agents' + # matrix accounts with it and never writes it. matrix-ctl mints and publishes + # it (`matrixCtlPolicyText` below). controllerPolicyText = '' path "auth/cert/certs/hive-*" { capabilities = ["create", "update", "read", "delete"] @@ -214,6 +218,10 @@ let path "${credentialMountPath}/data/swarm/agents/*" { capabilities = ["create", "read", "update"] } + + path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" { + capabilities = ["read"] + } ''; # The identity that copies authelia's minted OIDC client secrets into the @@ -278,10 +286,18 @@ let # restart would mint a second access token and invalidate the hive's. A read # here recovers one secret this principal itself wrote, which is a much # narrower grant than the publisher's would have been. + # + # The second stanza is the swarm appservice's token, which matrix-ctl mints + # inside the container and publishes here for the controller. `read` for the + # same reason: publish compares before it writes. matrixCtlPolicyText = '' path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" { capabilities = ["create", "update", "read"] } + + path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" { + capabilities = ["create", "update", "read"] + } ''; # Which hive matrix-ctl mints for. This host's own by default, which is right @@ -295,6 +311,12 @@ let # locally, which is the same degrade a store that was never deployed gives. matrixCtlHive = baoDeploy.matrixCtlHiveName; + # The swarm appservice token's leaf, the nix half of + # `swarm_secret_client::matrix::swarm_appservice_token_path`. Under + # `controller/`, the one kind no hive's policy reads: its sender is the + # homeserver's admin. + swarmAppserviceTokenLeaf = "swarm/controller/swarm-controller/matrix/appservice-token"; + # The KV v2 engine the controller writes agent credentials through. Named # once because the grant above and the `secrets enable` in the bootstrap unit # have to agree: a policy pointing at a mount nobody created is precisely the diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index de0b9838..6c56e9c3 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -227,11 +227,9 @@ let SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; }; - # Swarm-wide default for `PUT .../matrix-accounts/{account}`'s own - # `homeserver` field, for a request that omits one. Same shape as - # `authBridgeEnv` above: genuinely optional, gated on the option - # resolving rather than assumed. Not read by anything yet — see the - # option's own description. + # The swarm's homeserver: where `MintAgentMatrixAccount` creates each + # agent's own account. Gated on the option resolving: a swarm with no + # domain has no homeserver URL, and the mint node then fails by name. matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) { SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl; }; @@ -561,19 +559,24 @@ in matrixHomeserverUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = null; + # The same `chat.` ./hive-matrix.nix serves its vhost on + # (`gatewayHost`): a swarm runs one homeserver. + default = if swarmDomain == null then null else "https://chat.${swarmDomain}"; + defaultText = lib.literalExpression ''"https://chat.''${services.hyperhive.swarm.domain}"''; example = "https://matrix.example.org"; description = '' - Swarm-wide default homeserver for `PUT + Client-server API base of the swarm's homeserver. The controller + creates each agent's own matrix account there, with the swarm's + appservice token, and stores its token where the agent reads it. + `null` leaves agents with no matrix account: the mint node fails, + naming this option's variable, and the agent is deployed anyway. + + Also the swarm-wide default homeserver for `PUT .../matrix-accounts/{account}` requests that omit their own `homeserver` — see that route's own doc comment (`swarm-controller/src/matrix_account.rs`) for why the field is optional in token mode and what omitting it currently resolves to. - - `null` (the default) leaves that per-request resolution exactly as - it is today. **Not yet consulted by the route at all**: this option - only exists to carry the value in, ahead of the route being taught - to fall back to it. + That route does not consult it yet. ''; }; diff --git a/nix/module-eval/agent-matrix.nix b/nix/module-eval/agent-matrix.nix index 2d25857d..529a8766 100644 --- a/nix/module-eval/agent-matrix.nix +++ b/nix/module-eval/agent-matrix.nix @@ -18,6 +18,7 @@ let ; }) agent + agentWith runGroup ; @@ -45,6 +46,15 @@ let homeserver = "https://matrix.example.invalid"; }; }; + # The daemon that reads its tokens from the store, `main` included. Paired + # with `agentMatrix` above — same daemon, no store — so each case below can + # tell "carries the store's coordinates" from "every matrix daemon does". + agentMatrixBao = agentWith { + services.hyperhive.agent.bao.addr = "https://bao.t.local:8200"; + services.hyperhive.agent.matrix.url = "https://chat.t.local"; + }; + + daemon = machine: machine.systemd.services.hive-matrix-daemon; cases = [ { # A homeserver URL is the whole input: from it the module derives the @@ -102,6 +112,69 @@ let # No hive homeserver, so nothing may claim one. && !(env ? HIVE_MATRIX_URL); } + { + # The daemon reads this agent's tokens from the store ITSELF, as itself, + # from inside this container — `main` too, since the swarm mints it and + # no hive does. So it needs the same identity ./agent-forge-bao.nix's + # fetch carries, in its own credentials directory. + name = "the matrix daemon carries this agent's own store identity"; + ok = + let + u = daemon agentMatrixBao; + in + u.serviceConfig.LoadCredential == [ + "hive-agent-bao-cert" + "hive-agent-bao-key" + "hive-agent-bao-server-ca" + ] + && u.environment.BAO_ADDR == "https://bao.t.local:8200" + && u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert" + && u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"; + } + { + # The agent's name, and nothing derived from it: the daemon builds its + # store path and its cert-auth role from this one string. + name = "the matrix daemon is told which agent it is and not where its credentials live"; + ok = + let + e = (daemon agentMatrixBao).environment; + in + e.HIVE_AGENT_NAME == agentMatrixBao.services.hyperhive.agent.user.name + && !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e)); + } + { + # 🩸 A secret is a path: every `BAO_*` entry is the store's address or a + # file under this unit's own credentials directory, never bytes in an + # environment `/proc//environ` publishes. + name = "the matrix daemon is handed store paths and never store values"; + ok = + let + store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment; + in + store != { } + && lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store); + } + { + # A token the swarm mints or replaces in the store changes no file, so + # the path watcher never sees it. The timer is what re-starts a daemon + # that exited on a missing or replaced token. + name = "a store-backed matrix daemon is re-started while it is down"; + ok = + agentMatrixBao.systemd.timers.hive-matrix-daemon.timerConfig.OnUnitInactiveSec or null == "5min"; + } + { + # The absence arm for the four above: with no store, no identity, no + # timer, and the file is the whole mechanism. + name = "a matrix daemon on an agent with no store declares no identity and no timer"; + ok = + let + u = daemon agentMatrix; + in + !(u.serviceConfig ? LoadCredential) + && !(u.environment ? BAO_ADDR) + && !(u.environment ? HIVE_AGENT_NAME) + && !(agentMatrix.systemd.timers ? hive-matrix-daemon); + } ]; in runGroup "agent-matrix" cases diff --git a/nix/module-eval/bao-controller.nix b/nix/module-eval/bao-controller.nix index 22e1e573..da8883e9 100644 --- a/nix/module-eval/bao-controller.nix +++ b/nix/module-eval/bao-controller.nix @@ -59,6 +59,15 @@ let baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or ""); cases = [ + { + # No hive mints an agent's matrix account any more, so a controller that + # did not know the homeserver would create every agent without one. The + # default is the swarm's own `chat.` vhost, with no operator setting. + name = "the controller is told the swarm's homeserver by default"; + ok = + controllerNoStore.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_HOMESERVER_URL + or null == "https://chat.t.local"; + } { # Nothing asserted the PKI script before this, so a third leaf could be # added to it and every case still passed — measured, not assumed: the diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 7726dc2a..c3c7cfa7 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -334,12 +334,18 @@ let # are what keep it from drifting back — neither the `services/*` tree nor # a `hives/*` wildcard may appear, since either one hands matrix-ctl (or # a hive) reach beyond the single leaf it owns. - name = "matrix-ctl's grant is one hive's sender token path and nothing else"; + # + # The one other leaf is the swarm appservice token, which matrix-ctl + # mints and publishes for the controller. Counted, so a third stanza + # fails rather than riding along beside two correct ones. + name = "matrix-ctl's grant is one hive's sender token and the swarm appservice token, nothing else"; ok = let s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in lib.hasInfix "path \"secret/data/swarm/hives/h1/matrix/sender-token\" {" s + && lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"create\", \"update\", \"read\"]\n}" s + && lib.length (lib.splitString "path \"" s) == 3 && !(lib.hasInfix "secret/data/swarm/services" s) && !(lib.hasInfix "secret/data/swarm/agents" s) && !(lib.hasInfix "secret/data/swarm/hives/*" s) @@ -747,6 +753,14 @@ let in lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"read\", \"update\"]" s; } + { + # The swarm appservice token is a homeserver-admin credential. The + # controller mints agents' accounts with it and has no business replacing + # it: matrix-ctl is its one writer. Pinned as the whole stanza, so an + # added capability fails. + name = "the controller reads the swarm appservice token and cannot write it"; + ok = lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"read\"]\n}" baoGrantHere.systemd.services.swarm-bao-controller-policy.script; + } { # The policy above grants paths under a mount nothing else creates, so # the unit that writes the policy has to create it too — otherwise every diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index 19a0da5f..45ab3e46 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -392,18 +392,64 @@ let && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); } { - # 🩸 The privilege arm of the credential this slice publishes: the - # account it belongs to must not be a homeserver admin. Read on the - # rendered homeserver settings rather than on an option, because the - # grant was never an option — it was a boot command in `admin_execute`, - # and a command list is exactly the shape a later edit re-adds without - # anything noticing. - name = "the homeserver promotes no account to admin at boot"; + # 🩸 The privilege arm: exactly one account is a homeserver admin, the + # swarm appservice's sender, whose token only matrix-ctl and the + # controller read. Read on the rendered settings rather than on an + # option, because the grant is a boot command in `admin_execute`, and a + # command list is exactly the shape a later edit extends without anything + # noticing — so the list is compared whole, and a second entry fails. + name = "the homeserver promotes exactly the swarm sender to admin at boot, and no hive's sender"; ok = let g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global; in - !(g ? admin_execute) || g.admin_execute == [ ]; + g.admin_execute == [ "users make-user-admin @swarm:${g.server_name}" ] + && !(lib.any (c: lib.hasInfix "@hive-" c) g.admin_execute); + } + { + # Load-bearing rather than lenient: tuwunel aborts startup on a failing + # `admin_execute` command when this is false, and the homeserver must not + # stay down over a promotion. + name = "a failed admin promotion does not stop the homeserver"; + ok = + baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global.admin_execute_errors_ignore + or false; + } + { + # The swarm registration reaches tuwunel the way the hive's does: a + # `.yaml` credential in the directory `appservice_dir` names. + name = "tuwunel loads the swarm registration as a yaml credential"; + ok = lib.elem "swarm-appservice.yaml:/var/lib/swarm-matrix-appservice/swarm.yaml" ( + baoWithMatrix.containers.hive-matrix.config.systemd.services.tuwunel.serviceConfig.LoadCredential + ); + } + { + # A missing `LoadCredential` source fails tuwunel, so the render has to + # have run first — and it is local only, so it cannot fail on a store. + name = "the swarm registration is rendered before tuwunel and required by it, without the store"; + ok = + let + r = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-render; + in + lib.elem "tuwunel.service" r.before + && lib.elem "tuwunel.service" r.requiredBy + && lib.hasSuffix "swarm-matrix-ctl appservice render" r.serviceConfig.ExecStart + && !(r.environment ? BAO_ADDR); + } + { + # The absence arm for the four above: with no matrix-ctl identity there + # is nobody to publish the swarm token, so no admin, no registration and + # no render — rather than an admin credential nobody reads. + name = "a matrix container with no store identity has no swarm appservice and promotes nobody"; + ok = + let + c = matrixNoBaoIdentity.containers.hive-matrix.config; + g = c.services.matrix-tuwunel.settings.global; + in + !(g ? admin_execute) + && !(c.systemd.services ? swarm-matrix-appservice-render) + && !(c.systemd.services ? swarm-matrix-appservice-publish) + && !(lib.any (lib.hasPrefix "swarm-appservice.yaml") c.systemd.services.tuwunel.serviceConfig.LoadCredential); } { # 🩸 The arm the whole refusal exists for. Both readers are gated on their diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 412c9fa5..1e4dcf71 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -96,6 +96,9 @@ swarm-queue-client = { workspace = true, features = ["kv"] } # name and the object's shape are agreements between the two ends, and a # second spelling here would be a store this hive could not read. swarm-secret-client.workspace = true +# The appservice calls `matrix_account::agent_token` mints agents' accounts +# with — shared with `swarm-matrix-ctl`, which pins the same device id. +swarm-matrix-client.workspace = true # `agent_identity.rs` signs the per-agent client leaf the store authenticates # an agent container by. The one runtime signer in this tree — every other CA # here is a deploy-time `openssl` oneshot — because this one issues per agent diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 3af90807..35623a0a 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -113,6 +113,14 @@ enum SwarmNodeKind { /// Carries no hive: the token's store path has no hive segment, and the /// agent pulls it from wherever it runs. MintAgentForgeToken { agent: String }, + /// Make sure `agent` holds a live token for its own account on the swarm's + /// homeserver, creating the account with the swarm's appservice token + /// when it does not. See `matrix_account::agent_token` — including why a + /// stored token is read back before anything is minted. + /// + /// Carries no hive: the path it writes has no hive segment, so an agent + /// that moves between hives keeps one matrix identity. + MintAgentMatrixAccount { agent: String }, /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// store, so a freshly created agent does not start driving turns the /// moment it's deployed — the operator has to explicitly flip it to `Up`. @@ -139,6 +147,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), + SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(), SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(), SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), } @@ -157,7 +166,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } - | SwarmNodeKind::MintAgentForgeToken { agent } => { + | SwarmNodeKind::MintAgentForgeToken { agent } + | SwarmNodeKind::MintAgentMatrixAccount { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } @@ -209,6 +219,11 @@ struct WorkerDeps { /// see `wanted_writer`. `None` exactly when no swarm queue is configured /// on this host, same as `queue`. wanted: Option>, + /// Client-server API base of the swarm's homeserver, for the node that + /// mints agents' accounts on it. `None` when + /// `matrix_account::DEFAULT_HOMESERVER_ENV` is unset, which that node + /// **fails** on rather than skips, same as every other field here. + matrix_homeserver: Option>, } /// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be. @@ -304,21 +319,13 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, - SwarmNodeKind::MintAgentIdentity { hive, agent } => match deps.agent_ca { - None => Outcome::Failed( - "no agent certificate authority is configured on this host \ - (SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \ - so this agent has no identity at the swarm secret store" - .to_owned(), - ), - Some(authority) => { - match agent_identity::mint_and_verify(&authority, &agent, &hive).await { - Ok(()) => Outcome::Done, - Err(e) => Outcome::Failed(format!("{e:#}")), - } - } - }, + SwarmNodeKind::MintAgentIdentity { hive, agent } => { + mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await + } SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await, + SwarmNodeKind::MintAgentMatrixAccount { agent } => { + mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await + } SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted { None => Outcome::Failed( "no swarm queue is configured on this host, so no wanted-state \ @@ -338,6 +345,29 @@ async fn run_swarm_node( (builder, outcome) } +/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under +/// `clippy::too_many_lines`. +async fn mint_identity( + authority: Option<&agent_identity::Authority>, + agent: &str, + hive: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + let Some(authority) = authority else { + return Outcome::Failed( + "no agent certificate authority is configured on this host \ + (SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \ + so this agent has no identity at the swarm secret store" + .to_owned(), + ); + }; + match agent_identity::mint_and_verify(authority, agent, hive).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + } +} + /// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under /// `clippy::too_many_lines`. async fn mint_forge_token( @@ -359,6 +389,28 @@ async fn mint_forge_token( } } +/// The `MintAgentMatrixAccount` arm, lifted out for the same reason. +async fn mint_matrix_account( + homeserver: Option<&str>, + agent: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + // Loud on purpose: an agent quietly created without a matrix account is + // an agent nothing can talk to, and no hive mints one any more. + let Some(base) = homeserver else { + return Outcome::Failed(format!( + "no matrix homeserver configured on this host ({} unset), so no agent \ + matrix account can be minted", + matrix_account::DEFAULT_HOMESERVER_ENV + )); + }; + match matrix_account::agent_token::ensure_agent_matrix_account(base, agent).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + } +} + /// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns /// out not to be new: an agent that already has a declaration (other than /// `Destroyed`, which this treats as reusable) is left alone, so a retried @@ -1513,6 +1565,13 @@ fn declare_agent_job( agent: agent.to_owned(), }) .after_ok(create_forge_user); + // The agent's account on the swarm's homeserver. A root of its own: + // creating it needs neither an authelia subject nor a forge user nor a + // repo, and chaining it behind one of those would make an unrelated + // failure look like a matrix failure. + let mint_matrix = b.node(SwarmNodeKind::MintAgentMatrixAccount { + agent: agent.to_owned(), + }); // Declared before the deploy trigger so the pause is visible in the // wanted-state store before the hive brings the container up — see the // node's own doc comment for why "before", not just "eventually". Needs @@ -1553,6 +1612,11 @@ fn declare_agent_job( .after_ok(init_config) .after_any(mint_identity) .after_any(mint_forge_token) + // `after_any` for the same reason: the container reads this token + // rather than producing it, so the deploy must not overtake the mint — + // but a host with no homeserver configured must still create agents, + // and only this node fails, by name. + .after_any(mint_matrix) .after_ok(set_wanted); vec![create_identity.guid()] } @@ -1795,6 +1859,57 @@ fn queue_forge_token_mints( Ok(ids) } +/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes' +/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through +/// here, so a backfilled mint is the same node a new agent gets. +fn queue_matrix_account_mints( + sched: &Mutex>, + agents: Vec, +) -> Result> { + let mut sched = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut ids = Vec::with_capacity(agents.len()); + for agent in agents { + let queued = sched + .insert_job(None, |b| { + vec![ + b.node(SwarmNodeKind::MintAgentMatrixAccount { agent }) + .guid(), + ] + }) + .map_err(|e| anyhow::anyhow!("{e}"))?; + ids.extend(queued); + } + Ok(ids) +} + +/// The homeserver agents' own matrix accounts are minted on. Unset is this +/// controller creating agents with no matrix account, which the mint node +/// reports by name rather than `main` refusing to start. +fn configured_matrix_homeserver() -> Option> { + matrix_account::configured_default_homeserver() + .filter(|v| !v.is_empty()) + .map(Arc::from) +} + +/// Start the matrix-account backfill when a homeserver is configured. Lifted +/// out of `main` for `clippy::too_many_lines`. +fn spawn_matrix_account_backfill( + jobq: &Arc>>, + homeserver: Option>, +) { + let Some(base) = homeserver else { + return; + }; + let sched = Arc::clone(jobq); + matrix_account::agent_token::spawn(base, move |agents| { + if let Err(e) = queue_matrix_account_mints(&sched, agents) { + tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed"); + } + }); +} + /// Check an agent's forge token now, and mint one if it is missing or stale. /// /// The periodic pass (`forge::agent_token::spawn`) does the same every five @@ -2167,12 +2282,14 @@ async fn main() -> Result<()> { queue: status.as_ref().map(|s| s.queue_client()), agent_ca: load_agent_authority(), wanted: wanted_writer(status.as_ref()), + matrix_homeserver: configured_matrix_homeserver(), }; let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))); + spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone()); spawn_jobq_worker(Arc::clone(&jobq), deps); // Bound to a named variable, not `_` — dropping the provider stops its // `PeriodicReader`, so it must live as long as `main` does (which it @@ -2844,6 +2961,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -2898,6 +3016,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -2952,6 +3071,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -3004,6 +3124,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -3242,6 +3363,92 @@ mod tests { assert_eq!(kind.data(1)["agent"], "atlas"); } + #[test] + fn a_matrix_account_node_renders_the_agent() { + use hive_jobq_wire::WireNode as _; + + let kind = SwarmNodeKind::MintAgentMatrixAccount { + agent: "atlas".to_owned(), + }; + assert_eq!(kind.label(), "mint_agent_matrix_account"); + assert_eq!(kind.data(1)["agent"], "atlas"); + } + + /// Agent creation mints the matrix account as a root of its own, and the + /// deploy waits for it without being cancelled by it: a host with no + /// homeserver configured must still deploy the agent. + #[tokio::test] + async fn the_matrix_account_mint_is_a_root_and_does_not_block_the_deploy() { + use hive_jobq_wire::WireNode as _; + + let (state, sched) = state_with_roster(); + let _queued = super::create_agent( + axum::extract::State(state), + axum::Json(super::CreateAgentRequest { + name: "atlas".to_owned(), + hive: "pr1ma".to_owned(), + }), + ) + .await + .expect("a hive in the roster must be accepted"); + + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let graph = guard.graph(); + let find = |label: &str| { + graph + .nodes() + .find(|n| n.payload.label() == label) + .unwrap_or_else(|| panic!("the graph holds a {label} node")) + }; + let mint = find("mint_agent_matrix_account"); + assert!(mint.deps.is_empty(), "the mint waits for nothing"); + let when = find("trigger_deploy") + .deps + .iter() + .find_map(|d| match d { + hive_jobq::Dep::Node { id, when } if *id == mint.id => Some(*when), + _ => None, + }) + .expect("the deploy waits for the matrix mint"); + assert!( + when.accepts(hive_jobq::TerminalState::Failed), + "a host with no homeserver must not cancel the deploy; this edge \ + has to be `after_any`, not `after_ok`" + ); + } + + /// The backfill's queueing: one mint node per agent, nothing else. + #[test] + fn a_queued_matrix_mint_is_one_node_per_agent() { + use hive_jobq_wire::WireNode as _; + + let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + )); + let ids = super::queue_matrix_account_mints(&sched, vec!["a".to_owned(), "b".to_owned()]) + .expect("two jobs insert"); + assert_eq!(ids.len(), 2); + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut agents: Vec = guard + .graph() + .nodes() + .map(|n| { + assert_eq!(n.payload.label(), "mint_agent_matrix_account"); + n.payload.data(n.id.get())["agent"] + .as_str() + .expect("agent is a string") + .to_owned() + }) + .collect(); + agents.sort(); + assert_eq!(agents, ["a", "b"]); + } + /// The manual route and the periodic pass both go through /// `queue_forge_token_mints`, so this is the assertion that a backfilled /// mint is the same pair of nodes agent creation inserts: the forge user, diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index a9163382..59ebb0da 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -1,5 +1,13 @@ -//! Give one agent an external matrix account: put the credential in the -//! swarm's secret store, then tell that agent's hive it is there. +//! An agent's matrix accounts, from this daemon's two sides of them. +//! +//! **The internal one** — [`agent_token`] — is the agent's own `main` account on +//! the swarm's homeserver, minted with the swarm's appservice token and stored +//! where the agent's daemon reads it. See docs/swarm/credentials.md for why +//! `main` is the one name [`put_matrix_account`] refuses to write. +//! +//! **The external one** — [`put_matrix_account`] and everything under it — is +//! an account somewhere else that an operator hands us a credential for: put it +//! in the swarm's secret store, then tell that agent's hive it is there. //! //! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the //! value under its own identity and writes it into the agent's state dir. The @@ -31,6 +39,8 @@ use utoipa::ToSchema; use super::{AppState, error_problem, swarm_hive}; +pub mod agent_token; + fn default_mode() -> String { "token".to_owned() } @@ -41,9 +51,9 @@ fn default_mode() -> String { /// when a caller omits one. Read via [`configured_default_homeserver`], not /// directly — see that fn's doc. /// -/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default` -/// below exists for a later slice of this homeserver-default rollout to -/// call; this one only wires the config through. +/// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet +/// consulted by [`put_matrix_account`]: `homeserver_or_configured_default` +/// below exists for a later slice of this homeserver-default rollout to call. pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL"; /// `caller`'s own homeserver, or `default` when the caller left it unset. @@ -250,11 +260,11 @@ pub async fn put_matrix_account( Ok(Json(PutMatrixAccountResponse { user_id })) } -/// Whether `account` is the hive-internal name every hive declares per -/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment -/// for why this route must never write one. +/// Whether `account` is the agent's own account, which [`agent_token`] mints +/// and `nix/agent-modules/matrix.nix` declares per agent — see the call site's +/// own comment for why this route must never write one. fn is_reserved_account(account: &str) -> bool { - account == "main" + account == agent_token::ACCOUNT } /// Token-mode's only requirement: a token was actually given. Split out of diff --git a/swarm-controller/src/matrix_account/agent_token.rs b/swarm-controller/src/matrix_account/agent_token.rs new file mode 100644 index 00000000..4240b6bc --- /dev/null +++ b/swarm-controller/src/matrix_account/agent_token.rs @@ -0,0 +1,357 @@ +//! Each agent's own account on the swarm's homeserver: created here with the +//! **swarm's** appservice token, stored at `swarm/agents//matrix/main`, +//! and pulled from there by the agent's matrix daemon itself. +//! +//! The appservice token is minted inside the matrix container and published +//! to `swarm_secret_client::matrix::swarm_appservice_token_path`, which only +//! matrix-ctl and this daemon may read. No hive holds it, and no hive mints an +//! agent's account any more. +//! +//! ⚠️ **Every mint replaces the agent's live token.** Both calls pin the +//! device id `hyperhive-`, so a login for an existing account replaces +//! that device's token: the one in the store before, or the file token a hive +//! minted. That is why the decision reads the stored token back with `whoami` +//! ([`classify`]) instead of only checking that the store holds something, and +//! why a failed read never mints ([`Observed::Unknown`]). +//! +//! Two callers insert the same `MintAgentMatrixAccount` job node: agent +//! creation, and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`] +//! over every agent that holds a store identity — the same roster and shape as +//! `crate::forge::agent_token`. + +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use swarm_matrix_client::{self as homeserver, Whoami}; +use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, matrix, policy}; + +/// The account name every agent's own matrix credential is stored under. +/// +/// `main` is what `nix/agent-modules/matrix.nix` declares per agent and what +/// the agent's daemon reads first. ⚠️ `PUT .../matrix-accounts/{account}` +/// refuses this exact name (`super::is_reserved_account`): that route stores +/// an **external** account an operator supplies, and one called `main` would +/// overwrite this. Two writers, one reserved name, and the reservation is what +/// keeps them apart. +pub const ACCOUNT: &str = "main"; + +/// How often [`spawn`] re-checks every agent's account. +const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); + +/// Why an account's token has to be (re)minted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MintReason { + /// The store holds nothing for this agent: a new agent, or one whose + /// account a hive created before the swarm did this. + NotStored, + /// The homeserver does not know the stored token: another login on the + /// same device replaced it. + Revoked, + /// The stored token is live but belongs to another account. + OtherUser, +} + +/// What one look at an agent's stored token found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Probe { + /// Nothing at the agent's path. + NotStored, + /// The homeserver's verdict on the stored token. + Whoami(Whoami), +} + +/// What to do about one agent's account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// The stored token is a live token for this agent's own account. + Keep, + /// Mint and store a new one. + Mint(MintReason), +} + +/// What one pass found for one agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Observed { + /// Both reads worked, and this is what [`classify`] made of them. + Decided(Decision), + /// A read failed. Nothing is known, so nothing is done. + Unknown, +} + +/// The localpart of a full user id, `@:`. +fn localpart(user_id: &str) -> Option<&str> { + user_id.strip_prefix('@')?.split_once(':').map(|(l, _)| l) +} + +/// Decide what to do about `agent`'s account from what its stored token is. +pub fn classify(agent: &str, probe: &Probe) -> Decision { + match probe { + Probe::NotStored => Decision::Mint(MintReason::NotStored), + Probe::Whoami(Whoami::UnknownToken) => Decision::Mint(MintReason::Revoked), + Probe::Whoami(Whoami::User(user)) if localpart(user) == Some(agent) => Decision::Keep, + Probe::Whoami(Whoami::User(_)) => Decision::Mint(MintReason::OtherUser), + } +} + +/// The agents a pass mints for. +/// +/// [`Observed::Unknown`] never mints: a homeserver or store outage must not +/// turn into a new token for every agent, each of which kills the one the +/// agent is running on. +pub fn plan(observed: &[(String, Observed)]) -> Vec { + observed + .iter() + .filter(|(_, o)| matches!(o, Observed::Decided(Decision::Mint(_)))) + .map(|(agent, _)| agent.clone()) + .collect() +} + +/// The swarm appservice token, or an error naming why there is none. +async fn appservice_token(store: &SecretStore) -> Result { + let path = matrix::swarm_appservice_token_path()?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + match stored { + Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()), + _ => bail!( + "nothing at {path}: the matrix container has not published the swarm \ + appservice token (swarm-matrix-appservice-publish)" + ), + } +} + +/// What the store and the homeserver say about `agent`'s stored token. +async fn probe( + store: &SecretStore, + http: &reqwest::Client, + base: &str, + agent: &str, +) -> Result { + let path = matrix::account_path(agent, ACCOUNT)?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + let Some(stored) = stored else { + return Ok(Probe::NotStored); + }; + let verdict = homeserver::whoami(http, base, stored.value.trim()) + .await + .with_context(|| format!("asking the homeserver about the token at {path}"))?; + Ok(Probe::Whoami(verdict)) +} + +/// Make sure `agent` holds a live token for its own account on the swarm's +/// homeserver at `base`, creating the account or logging in to it when it +/// does not. The whole job of the `MintAgentMatrixAccount` node. +/// +/// Reads the new token back with `whoami` before reporting success, so the +/// node does not report success on a write nobody has read. A crash between +/// the login and the write leaves a dead token in the store, which the next +/// pass classifies as [`MintReason::Revoked`]. +/// +/// # Errors +/// When the store or the homeserver refuses a step, the swarm appservice +/// token has not been published, or the new token authenticates as someone +/// else. +pub async fn ensure_agent_matrix_account(base: &str, agent: &str) -> Result<()> { + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + let as_token = appservice_token(&store).await?; + let http = homeserver::client()?; + let reason = match classify(agent, &probe(&store, &http, base, agent).await?) { + Decision::Keep => { + tracing::debug!(agent, "agent matrix account is current; left as it is"); + return Ok(()); + } + Decision::Mint(reason) => reason, + }; + + let token = match homeserver::register(&http, base, agent, &as_token).await? { + homeserver::Registered::Token(token) => token, + // An agent minted before, or one a hive created back when hives did + // this: log in as the appservice on the same device instead. + homeserver::Registered::AlreadyExists => { + homeserver::appservice_login(&http, base, agent, &as_token).await? + } + }; + let path = matrix::account_path(agent, ACCOUNT)?; + store + .write( + &path, + &matrix::Credential { + value: token.clone(), + homeserver: Some(base.to_owned()), + }, + ) + .await + .with_context(|| format!("storing {agent}'s matrix token at {path}"))?; + + match homeserver::whoami(&http, base, &token) + .await + .with_context(|| format!("using {agent}'s new matrix token"))? + { + Whoami::User(user) if localpart(&user) == Some(agent) => {} + _ => bail!("{agent}'s new matrix token does not authenticate as {agent}"), + } + tracing::info!(agent, ?reason, %path, "agent matrix account token minted and stored"); + Ok(()) +} + +/// One pass: every agent holding a store identity, observed. +/// +/// Fails as a whole, rather than per agent, when the swarm appservice token is +/// not published: every mint would fail on it, and one message says so. +async fn observe_all(base: &str) -> Result> { + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + appservice_token(&store).await?; + let http = homeserver::client()?; + let roles = store + .list_cert_roles(DEFAULT_CERT_MOUNT) + .await + .context("listing the store's cert-auth roles")?; + let mut observed = Vec::new(); + for agent in policy::agents_from_role_names(&roles) { + let o = match probe(&store, &http, base, &agent).await { + Ok(p) => Observed::Decided(classify(&agent, &p)), + Err(e) => { + tracing::warn!(agent, error = %format!("{e:#}"), "agent matrix account: read failed"); + Observed::Unknown + } + }; + observed.push((agent, o)); + } + Ok(observed) +} + +/// Check every agent's account now and every [`RECONCILE_INTERVAL`] after, and +/// hand the agents that need a token to `enqueue`, which inserts a +/// `MintAgentMatrixAccount` node for each. +/// +/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the +/// agents that can read what the node writes. A pass that fails is logged and +/// retried on the next tick; it never stops the daemon. +pub fn spawn(base: Arc, enqueue: impl Fn(Vec) + Send + 'static) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + loop { + ticker.tick().await; + match observe_all(&base).await { + Ok(observed) => { + let agents = plan(&observed); + if agents.is_empty() { + tracing::debug!( + checked = observed.len(), + "agent matrix accounts: all current" + ); + } else { + tracing::info!( + checked = observed.len(), + minting = agents.len(), + "agent matrix accounts: queueing mints" + ); + enqueue(agents); + } + } + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + retry_in_s = RECONCILE_INTERVAL.as_secs(), + "agent matrix accounts: pass failed; retrying next tick" + ), + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(id: &str) -> Probe { + Probe::Whoami(Whoami::User(id.to_owned())) + } + + #[test] + fn a_live_token_for_the_agent_is_kept() { + assert_eq!(classify("atlas", &user("@atlas:t.local")), Decision::Keep); + } + + #[test] + fn nothing_stored_mints() { + assert_eq!( + classify("atlas", &Probe::NotStored), + Decision::Mint(MintReason::NotStored) + ); + } + + #[test] + fn a_replaced_token_mints() { + // The shape a hive re-login on the same device leaves behind. + assert_eq!( + classify("atlas", &Probe::Whoami(Whoami::UnknownToken)), + Decision::Mint(MintReason::Revoked) + ); + } + + #[test] + fn a_token_for_another_account_mints() { + // Localpart compared whole, so a prefix of the agent's name is not it. + for other in ["@argus:t.local", "@atlas2:t.local", "@atla:t.local"] { + assert_eq!( + classify("atlas", &user(other)), + Decision::Mint(MintReason::OtherUser), + "{other}" + ); + } + } + + #[test] + fn a_pass_mints_only_what_it_knows_needs_one() { + let observed = [ + ("a".to_owned(), Observed::Decided(Decision::Keep)), + ( + "b".to_owned(), + Observed::Decided(Decision::Mint(MintReason::NotStored)), + ), + ("c".to_owned(), Observed::Unknown), + ( + "d".to_owned(), + Observed::Decided(Decision::Mint(MintReason::Revoked)), + ), + ]; + assert_eq!(plan(&observed), ["b", "d"]); + } + + #[test] + fn an_outage_plans_nothing() { + // Each mint replaces the token an agent is running on, so a pass that + // could not read must not mint for everyone. + let observed = [ + ("a".to_owned(), Observed::Unknown), + ("b".to_owned(), Observed::Unknown), + ]; + assert!(plan(&observed).is_empty()); + } + + #[test] + fn the_published_path_is_the_hiveless_one_the_agent_reads() { + // The literal is the point, and the absence of a hive segment is the + // half of it that was decided: an agent's account follows the agent. + assert_eq!( + matrix::account_path("atlas", ACCOUNT).expect("a plain name is legal"), + "swarm/agents/atlas/matrix/main" + ); + } + + #[test] + fn the_account_name_is_the_one_the_agent_module_declares() { + // `nix/agent-modules/matrix.nix` renders this as a `matrixAccounts` + // key and nothing wires an override across. + assert_eq!(ACCOUNT, "main"); + } +} diff --git a/swarm-matrix-client/Cargo.toml b/swarm-matrix-client/Cargo.toml new file mode 100644 index 00000000..4d8bd38b --- /dev/null +++ b/swarm-matrix-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "swarm-matrix-client" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/swarm-matrix-client/README.md b/swarm-matrix-client/README.md new file mode 100644 index 00000000..36a344bd --- /dev/null +++ b/swarm-matrix-client/README.md @@ -0,0 +1,17 @@ +# swarm-matrix-client + +The appservice calls against the swarm's homeserver, shared by the two binaries +that make them: `swarm-matrix-ctl` (inside the matrix container, for a hive's +sender account) and `swarm-controller` (for each agent's account, with the +swarm's own appservice token). + +Three calls, all client-server API: register an account as the appservice, log +in to an existing one as the appservice, and ask a token who it is. Both mints +pin the device id `hyperhive-`, so a second login **replaces** that +device's token instead of adding a device. + +## 🩸 A secret is a path, never a value + +Every error here is built from the response's status and its `errcode`, never +its body: a successful `/register` or `/login` body is an access token, and an +error body is one malformed response away from being the same bytes. diff --git a/swarm-matrix-ctl/src/homeserver.rs b/swarm-matrix-client/src/lib.rs similarity index 70% rename from swarm-matrix-ctl/src/homeserver.rs rename to swarm-matrix-client/src/lib.rs index 05ac19e0..c7c4c4fa 100644 --- a/swarm-matrix-ctl/src/homeserver.rs +++ b/swarm-matrix-client/src/lib.rs @@ -1,4 +1,6 @@ -//! The two calls the mint ladder is made of, against the homeserver next door. +//! The appservice calls against the swarm's homeserver: register, log in, and +//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`; +//! see the README for why both mint through the same device id. //! //! 🩸 **Every error in this module is built from the response's `status` and //! its `errcode`, never its body.** A successful `/register` or `/login` body @@ -8,8 +10,8 @@ use anyhow::{Context, Result, bail}; -/// Client-server API calls are one round trip each against a homeserver in the -/// same netns; a slow one is a broken one. +/// Every call here is one client-server API round trip; a slow one is a broken +/// one. const TIMEOUT_SECS: u64 = 10; /// Bytes of the throwaway password `/register` is given. @@ -21,11 +23,11 @@ const PASSWORD_BYTES: usize = 32; /// What the homeserver said about a registration attempt. /// -/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the -/// *expected* answer here — the hive's `@hive-:` account is the appservice -/// registration's own `sender_localpart`, so the homeserver creates it at startup, before -/// anything gets to ask — and an expected answer should not have to be -/// recovered from a formatted message. +/// An enum rather than a string match on the error text: `M_USER_IN_USE` is an +/// *expected* answer — a registration's own `sender_localpart` is created by +/// the homeserver at startup, and an agent minted once before already exists — +/// and an expected answer should not have to be recovered from a formatted +/// message. pub enum Registered { /// A fresh account, and the access token minted with it. Token(String), @@ -68,7 +70,7 @@ pub async fn register( // request carries the as_token. "type": "m.login.application_service", "username": localpart, - "password": random_password()?, + "password": random_hex(PASSWORD_BYTES)?, "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), "inhibit_login": false, @@ -140,6 +142,52 @@ pub async fn appservice_login( access_token(&json) } +/// What the homeserver says a token is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Whoami { + /// A live token, and the full user id it authenticates as. + User(String), + /// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a + /// later login on the same device replaced it or it was revoked. + UnknownToken, +} + +/// Ask the homeserver who `token` is. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is +/// [`Whoami::UnknownToken`], because it is a verdict about the token and the +/// rest are not. +pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result { + let resp = client + .get(format!("{base}/_matrix/client/v3/account/whoami")) + .bearer_auth(token) + .send() + .await + .context("GET /account/whoami: sending the request")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("GET /account/whoami: decoding the response as JSON")?; + whoami_verdict(status, &json) +} + +/// [`whoami`]'s reading of a response, apart from the IO. +fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result { + if status.is_success() { + return json["user_id"] + .as_str() + .map(|u| Whoami::User(u.to_owned())) + .context("the homeserver's whoami response carried no `user_id`"); + } + if errcode(json) == Some("M_UNKNOWN_TOKEN") { + return Ok(Whoami::UnknownToken); + } + bail!("the homeserver refused whoami: {}", why(status, json)) +} + /// The device every token this binary mints is pinned to. fn device_id(localpart: &str) -> String { format!("hyperhive-{localpart}") @@ -195,18 +243,18 @@ fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { } } -/// A throwaway password for [`register`], as hex. +/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s +/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints. /// -/// From `/dev/urandom` directly rather than through an RNG crate: this is the -/// one random value the binary needs, and the kernel is already the source any -/// such crate would reach for here. +/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is +/// already the source any such crate would reach for here. /// /// # Errors /// When `/dev/urandom` cannot be read. -fn random_password() -> Result { +pub fn random_hex(bytes: usize) -> Result { use std::io::Read as _; - let mut buf = [0u8; PASSWORD_BYTES]; + let mut buf = vec![0u8; bytes]; std::fs::File::open("/dev/urandom") .context("opening /dev/urandom")? .read_exact(&mut buf) @@ -275,11 +323,48 @@ mod tests { assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); } + #[test] + fn whoami_reads_the_user_of_a_live_token() { + let json = + serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" }); + assert_eq!( + whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"), + Whoami::User("@atlas:t.local".to_owned()) + ); + } + + #[test] + fn a_replaced_token_is_a_verdict_and_not_an_error() { + // The arm a mint decides on: this is how a token some other login on + // the same device replaced looks. + let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" }); + assert_eq!( + whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"), + Whoami::UnknownToken + ); + } + + #[test] + fn any_other_refusal_is_an_error_so_nobody_mints_on_it() { + // The control for the arm above: a 401 with another code, or a 5xx, + // says nothing about the token, and a caller that minted on it would + // rotate every token during an outage. + for (status, json) in [ + ( + reqwest::StatusCode::UNAUTHORIZED, + serde_json::json!({ "errcode": "M_MISSING_TOKEN" }), + ), + (reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})), + ] { + assert!(whoami_verdict(status, &json).is_err(), "{status}"); + } + } + #[test] fn a_minted_password_is_hex_of_the_declared_length() { // The control on the hex fold: a short or non-hex password would be // accepted by the homeserver and only surface much later, if at all. - let pw = random_password().expect("/dev/urandom is readable"); + let pw = random_hex(PASSWORD_BYTES).expect("/dev/urandom is readable"); assert_eq!(pw.len(), PASSWORD_BYTES * 2); assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); } diff --git a/swarm-matrix-ctl/Cargo.toml b/swarm-matrix-ctl/Cargo.toml index 3d308778..155bf71a 100644 --- a/swarm-matrix-ctl/Cargo.toml +++ b/swarm-matrix-ctl/Cargo.toml @@ -14,8 +14,10 @@ anyhow.workspace = true # single-purpose binary: the next thing that has to run in the matrix container # is a subcommand here, not a new crate. clap.workspace = true -reqwest.workspace = true serde_json.workspace = true +# The appservice calls, shared with `swarm-controller`, which mints agents' +# accounts through the same device id. +swarm-matrix-client.workspace = true # The agreement this binary is one end of: where the credential lives, what the # object at that path holds, and the `BAO_*` spellings the unit sets. swarm-secret-client.workspace = true diff --git a/swarm-matrix-ctl/src/appservice.rs b/swarm-matrix-ctl/src/appservice.rs new file mode 100644 index 00000000..da78df21 --- /dev/null +++ b/swarm-matrix-ctl/src/appservice.rs @@ -0,0 +1,279 @@ +//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice +//! registration: minted here, loaded by the homeserver beside us, and published +//! to the one store path `swarm-controller` reads it from. +//! +//! Two verbs, because they have opposite failure rules: +//! +//! - [`render`] runs before tuwunel and touches nothing but this container's +//! state dir. tuwunel loads the file it writes through `LoadCredential`, and +//! a missing credential source fails the homeserver's start, so this half +//! must not be able to fail on a network. +//! - [`publish`] runs after it and needs the store. A sealed store delays it +//! and nothing else. +//! +//! "Only once": the token file in the state dir is the record. [`render`] +//! mints only when it is absent and re-renders from it every time; [`publish`] +//! writes the store only when the store's copy differs. +//! +//! This registration's sender is promoted to homeserver admin at boot +//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to +//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no +//! hive's policy reaches — and to nowhere else. + +use std::io::Write as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, Settings}, + matrix, +}; + +use crate::registration; + +/// This container's state dir for the registration and its two tokens. +const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR"; +/// The registration's `sender_localpart`: the account the homeserver creates +/// for it and the one `admin_execute` promotes. +const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER"; +/// The user namespace regex, rendered by nix beside the hive registration's. +const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX"; +/// Role on the store's `cert` auth mount that [`publish`] logs in with. +const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE"; + +/// The registration's `id`. Distinct from the hive registration's +/// (`hyperhive`): tuwunel refuses two registrations with one id. +const ID: &str = "swarm"; +/// File names inside [`ENV_DIR`]. `REGISTRATION` is what tuwunel loads. +const AS_TOKEN: &str = "as-token"; +const HS_TOKEN: &str = "hs-token"; +const REGISTRATION: &str = "swarm.yaml"; +/// Random bytes per token, as the hive registration's renderer uses. +const TOKEN_BYTES: usize = 32; + +/// Read a required variable. +fn required(get: &impl Fn(&str) -> Option, var: &'static str) -> Result { + get(var) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{var} is unset or empty")) +} + +/// Mint the tokens if absent and render the registration from them. +/// +/// # Errors +/// If a variable is missing, or the state dir cannot be read or written. +pub fn render() -> Result<()> { + let get = |k: &str| std::env::var(k).ok(); + let dir = PathBuf::from(required(&get, ENV_DIR)?); + let sender = required(&get, ENV_SENDER)?; + let regex = required(&get, ENV_USER_REGEX)?; + render_into(&dir, &sender, ®ex)?; + tracing::info!(path = %dir.join(REGISTRATION).display(), "rendered the swarm appservice registration"); + Ok(()) +} + +/// [`render`] against an explicit directory, so a test can run it twice. +fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> { + let as_token = existing_or_minted(&dir.join(AS_TOKEN))?; + let hs_token = existing_or_minted(&dir.join(HS_TOKEN))?; + write_secret( + &dir.join(REGISTRATION), + ®istration_yaml(sender, regex, &as_token, &hs_token), + ) +} + +/// The token at `path`, minting and writing one first when there is none. +fn existing_or_minted(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + } + let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?; + write_secret(path, &token)?; + tracing::info!(path = %path.display(), "minted a swarm appservice token"); + Ok(token) +} + +/// Write `contents` to `path` as a `0600` file, through a rename so a reader +/// never sees half of it. +fn write_secret(path: &Path, contents: &str) -> Result<()> { + let tmp = path.with_extension("tmp"); + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp) + .with_context(|| format!("creating {}", tmp.display()))?; + f.write_all(contents.as_bytes()) + .and_then(|()| f.sync_all()) + .with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("renaming onto {}", path.display())) +} + +/// The registration, in the shape `hive-matrix.nix` renders the hive's. +/// +/// `exclusive: false` for that file's reason: an exclusive namespace does not +/// widen what this appservice may do, it refuses everyone else — and the hive +/// registration covers the same names until it is retired. +fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str) -> String { + format!( + "id: {ID}\n\ + url: null\n\ + sender_localpart: {sender}\n\ + rate_limited: false\n\ + namespaces:\n \ + users:\n \ + - exclusive: false\n \ + regex: '{regex}'\n \ + aliases: []\n \ + rooms: []\n\ + as_token: {as_token}\n\ + hs_token: {hs_token}\n" + ) +} + +/// Whether the store needs the local token written to it. +fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool { + stored.is_none_or(|c| c.value.trim() != local) +} + +/// Write the rendered registration's `as_token` to the store, unless the store +/// already holds it. +/// +/// # Errors +/// If a variable is missing, the registration has not been rendered, or the +/// store refuses the login, the read or the write. +pub async fn publish() -> Result<()> { + let get = |k: &str| std::env::var(k).ok(); + let dir = PathBuf::from(required(&get, ENV_DIR)?); + let cert_role = required(&get, ENV_CERT_ROLE)?; + let registration = dir.join(REGISTRATION); + let local = registration::as_token(®istration.to_string_lossy())?; + + let settings = Settings::from_env().context("reading the store's BAO_* environment")?; + let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT) + .await + .with_context(|| { + format!("logging in to the swarm secret store as cert role {cert_role}") + })?; + let path = matrix::swarm_appservice_token_path()?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + if !needs_publish(stored.as_ref(), &local) { + tracing::info!(%path, "the swarm appservice token is already published"); + return Ok(()); + } + store + .write( + &path, + &matrix::Credential { + value: local, + homeserver: None, + }, + ) + .await + .with_context(|| format!("writing the swarm appservice token to {path}"))?; + tracing::info!(%path, "published the swarm appservice token"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$"; + + fn scratch() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "swarm-appservice-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after the epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + #[test] + fn the_rendered_registration_carries_the_token_it_minted() { + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("renders"); + let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy()) + .expect("the as_token line parses"); + assert_eq!(token.len(), TOKEN_BYTES * 2); + assert_eq!( + std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"), + token + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_second_render_keeps_the_token() { + // "Only once": a re-mint on every boot would hand the controller a + // token the homeserver no longer loads until publish catches up. + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("first"); + let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered"); + render_into(&dir, "swarm", REGEX).expect("second"); + let second = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered"); + assert_eq!(first, second); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_registration_is_the_swarms_and_not_the_hives() { + let y = registration_yaml("swarm", REGEX, "aa", "bb"); + assert!(y.starts_with("id: swarm\n"), "{y}"); + assert!(y.contains("\nsender_localpart: swarm\n"), "{y}"); + assert!(y.contains("\n - exclusive: false\n"), "{y}"); + assert!(y.contains(&format!("regex: '{REGEX}'")), "{y}"); + assert!(y.contains("\nurl: null\n"), "{y}"); + } + + #[test] + fn the_registration_and_tokens_are_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("renders"); + for f in [AS_TOKEN, HS_TOKEN, REGISTRATION] { + let mode = std::fs::metadata(dir.join(f)) + .expect("exists") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "{f}"); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn publish_writes_only_what_the_store_lacks() { + let same = matrix::Credential { + value: "aa".to_owned(), + homeserver: None, + }; + assert!(!needs_publish(Some(&same), "aa")); + assert!(needs_publish(None, "aa")); + let other = matrix::Credential { + value: "bb".to_owned(), + homeserver: None, + }; + assert!(needs_publish(Some(&other), "aa")); + } + + #[test] + fn every_variable_is_scoped_to_the_verb() { + for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] { + assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}"); + } + } +} diff --git a/swarm-matrix-ctl/src/main.rs b/swarm-matrix-ctl/src/main.rs index 0008e919..9a734195 100644 --- a/swarm-matrix-ctl/src/main.rs +++ b/swarm-matrix-ctl/src/main.rs @@ -7,18 +7,19 @@ //! identity plumbing to add one action, so the next thing that has to run in //! here is a verb below, not a new crate. //! -//! Today that is one verb, [`mint`]: publish the appservice sender account's -//! homeserver access token to the swarm's secret store, once. +//! [`mint`] publishes a hive's appservice sender token to the swarm's secret +//! store, once. [`appservice`] mints the **swarm's** own appservice +//! registration and publishes its token for `swarm-controller`. //! //! It lives in the container because the appservice `as_token` that authorises //! the mint is *already* there — the registration tuwunel loads is bind-mounted //! in — so no second holder of that secret is created. //! //! 🩸 **A secret is a path, never a value.** The only identifier any verb here -//! logs is the store path; see `homeserver`'s module doc for the same rule +//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule //! applied to error messages. -mod homeserver; +mod appservice; mod mint; mod registration; @@ -44,6 +45,20 @@ enum Command { /// no flags, because a systemd `Environment=` block is what a nix module /// can render and a command line full of paths is not. Mint, + /// The swarm's own appservice registration, whose sender is the + /// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`. + #[command(subcommand)] + Appservice(Appservice), +} + +#[derive(Debug, Subcommand)] +enum Appservice { + /// Mint the tokens when absent and render the registration tuwunel loads. + /// Local only: it runs before the homeserver and must not need a network. + Render, + /// Write the rendered `as_token` to the swarm secret store when the + /// store's copy differs. + Publish, } #[tokio::main] @@ -57,6 +72,8 @@ async fn main() -> Result<()> { match Cli::parse().command { Command::Mint => mint::run().await, + Command::Appservice(Appservice::Render) => appservice::render(), + Command::Appservice(Appservice::Publish) => appservice::publish().await, } } @@ -80,6 +97,25 @@ mod tests { /// The control: without it the case above passes on a parser that accepts /// anything. + /// The two units name these verbs, same reason as the test above. + #[test] + fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() { + let cli = + Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "render"]).expect("a verb"); + assert!(matches!( + cli.command, + Command::Appservice(Appservice::Render) + )); + let cli = + Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb"); + assert!(matches!( + cli.command, + Command::Appservice(Appservice::Publish) + )); + Cli::try_parse_from(["swarm-matrix-ctl", "appservice"]) + .expect_err("a sub-verb is required"); + } + #[test] fn an_unknown_verb_is_refused() { Cli::try_parse_from(["swarm-matrix-ctl", "conjure"]) diff --git a/swarm-matrix-ctl/src/mint.rs b/swarm-matrix-ctl/src/mint.rs index a72f848b..465b49a6 100644 --- a/swarm-matrix-ctl/src/mint.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -21,7 +21,9 @@ use swarm_secret_client::{ matrix, }; -use crate::{homeserver, registration}; +use swarm_matrix_client as homeserver; + +use crate::registration; /// Role on the store's `cert` auth mount to log in with. Its policy is what /// allows the write below; the certificate the `BAO_*` variables name has to diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index beb97837..352546cf 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -93,6 +93,28 @@ pub fn appservice_token_path(hive: &str) -> Result { Ok(format!("{prefix}/matrix/appservice-token")) } +/// The name segment of the controller's own subtree, and the cert-auth role it +/// logs in under (`swarm-controller`'s `store::CERT_ROLE`). +const CONTROLLER: &str = "swarm-controller"; + +/// The path holding the **swarm's** appservice token: the one registration +/// on the swarm's homeserver that is not a hive's, whose sender is promoted +/// to homeserver admin at boot. +/// +/// The matrix container mints it and publishes it here; `swarm-controller` +/// reads it to create agents' accounts. Under [`Kind::Controller`] because +/// that is the one kind [`crate::policy::render`] grants no hive: every hive +/// reads `agents/*`, its own `hives//*` and `services/*`, so under any +/// of those this admin credential would be readable by every hive. +/// +/// # Errors +/// Never in practice: the name segment is a constant. The `Result` is +/// [`principal_prefix`]'s. +pub fn swarm_appservice_token_path() -> Result { + let prefix = principal_prefix(Kind::Controller, CONTROLLER)?; + Ok(format!("{prefix}/matrix/appservice-token")) +} + /// What an account's path holds: the token, plus the homeserver it belongs to. /// /// The homeserver rides with the token rather than on the queue notice that @@ -215,6 +237,39 @@ mod tests { assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); } + #[test] + fn the_swarm_appservice_token_lands_under_the_controller() { + assert_eq!( + swarm_appservice_token_path().expect("a constant segment"), + "swarm/controller/swarm-controller/matrix/appservice-token" + ); + } + + #[test] + fn no_hive_policy_reaches_the_swarm_appservice_token() { + // 🩸 The swarm's sender is a homeserver admin, so its token must not + // be under any stanza a hive's policy renders. Checked against the + // rendered document rather than against the prefix, because the + // document is what the store enforces. + let doc = crate::policy::render("pr1ma").expect("a plain name is legal"); + // Every stanza is `path "secret/data/*" { … }`. + let granted: Vec<&str> = doc + .lines() + .filter_map(|l| l.strip_prefix("path \"secret/data/")) + .filter_map(|p| p.strip_suffix("*\" {")) + .collect(); + let reads = |path: &str| granted.iter().any(|g| path.starts_with(g)); + let path = swarm_appservice_token_path().expect("a constant segment"); + assert!(!reads(&path), "{path} is readable under {granted:?}"); + // The control: an agent's account path IS under a hive stanza, so the + // assertion above can fail at all. + let agent = account_path("atlas", "main").expect("legal"); + assert!( + reads(&agent), + "{agent} should be readable under {granted:?}" + ); + } + #[test] fn a_segment_cannot_escape_its_own_directory() { // Each of these is a *different* way to address another agent's tree,