diff --git a/Cargo.lock b/Cargo.lock index 21b3cd8b..097329b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,7 +1989,6 @@ dependencies = [ "schemars", "serde", "serde_json", - "swarm-secret-client", "tokio", "tracing", "tracing-subscriber", @@ -4914,7 +4913,6 @@ dependencies = [ "sha2 0.11.0", "strum", "swarm-authelia-bridge-sock", - "swarm-matrix-client", "swarm-queue-client", "swarm-secret-client", "time", @@ -4937,23 +4935,14 @@ dependencies = [ "swarm-queue-client", ] -[[package]] -name = "swarm-matrix-client" -version = "0.1.0" -dependencies = [ - "anyhow", - "reqwest", - "serde_json", -] - [[package]] name = "swarm-matrix-ctl" version = "0.1.0" dependencies = [ "anyhow", "clap", + "reqwest", "serde_json", - "swarm-matrix-client", "swarm-secret-client", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index d01321e2..edae4e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", - "swarm-matrix-client", "swarm-matrix-ctl", "swarm-nats-auth", "swarm-queue-client", @@ -102,7 +101,6 @@ hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } -swarm-matrix-client = { path = "swarm-matrix-client" } swarm-queue-client = { path = "swarm-queue-client" } swarm-secret-client = { path = "swarm-secret-client" } thiserror = "2" diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 6f697e73..da4e4db5 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -569,9 +569,8 @@ so on a real hive (where hive-c0re renders that URL per agent) every agent has one; a `null` URL with no operator-declared account is the "this agent has no matrix" state. -**First-boot ordering**: a token can arrive after the container comes -up — a file the hive delivers, or a token the swarm mints into the store. -Without the path-trigger sibling +**First-boot ordering**: hive-c0re provisions the matrix token AFTER +agent containers come up. Without the path-trigger sibling (`systemd.paths.hive-matrix-daemon`, `PathExistsGlob = /matrix-token*` — the trailing `*` also catches a secondary multi-account token like `matrix-token-ccc`), the daemon @@ -579,9 +578,7 @@ would exit 0 quietly the first time it ran and the MCP would have no daemon until the next restart. The `.path` unit makes the appearance of the token re-fire the service so the daemon comes alive in the same boot cycle as -provisioning. A token in the store changes no file, so a store-backed agent -also gets a timer that restarts the daemon five minutes after it last -exited. The same token watcher also drives avatar setting: on a +provisioning. The same token watcher also drives avatar setting: on a restart the daemon re-runs each account's bring-up, which sets the avatar (see below). diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 28457c02..7f0d0b4b 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -44,8 +44,7 @@ hivectl agent ruth rebuild You don't need to do anything else. The controller's next pass creates ruth's forge user and mints her token, and her container fetches it within -about ten minutes. The same identity puts her in the matrix pass too (step -6). `swarmctl agent mint-forge-token ruth` skips the wait for +about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for the pass. A hive without a swarm secret store has no path to a forge token for ruth at all. @@ -238,6 +237,10 @@ control: [`swarm/ui.md`](../swarm/ui.md). # Ensure the appservice's sender account exists first hivectl matrix sync-admin +# Provision ruth's own matrix account — same bootstrap-bypass reasoning +# as forge above, still a required manual step. +hivectl matrix create-user ruth + # Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' @@ -246,12 +249,6 @@ hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' hivectl matrix promote-user mara ``` -ruth's own matrix account comes from the swarm, like every agent's: -`swarm-controller` creates it within five minutes of her holding a store -identity (step 1), and her matrix daemon reads its token from the store. -Without that identity she has no matrix account, and this hive no longer -creates one. - Swarm SSO creates the human operator's own matrix account instead of a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 2b3a34a8..992de996 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -129,52 +129,31 @@ a token. the bind-mount path. It reads only `.yaml`/`.yml` entries from there, so the sibling credentials are invisible to it. The `.yaml` suffix on the credential id is what makes this work. -5. **hive-c0re** reads the appservice token and creates its own - `@hive-:` 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. +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. ### 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; the -homeserver's `admin_execute` promotes only `@swarm`. +anything. It's an **ordinary account**: nothing promotes it, and the +homeserver runs no `admin_execute` for it. **One account per hive.** The localpart carries the hive's name, so a swarm whose hives share a homeserver gives each of them its own identity: the @@ -206,8 +185,10 @@ when its sender is already an admin. They're swarm-level operations, rehomed to the swarm tier rather than granted here; from the hive, `@hive-:` has no admin sender to make that call with, so both get the admin room's refusal rather than an over-privileged credential that -every other call site would also carry. The swarm's own sender is the -admin they need; moving them there is separate work. +every other call site would also carry. The one hive-side path that +depends on them is the automatic password recovery for an agent that +has lost its stored password — the admin-sender limitation doesn't +touch the ordinary appservice re-login above.
Upgrading a hive that shared one sender account with every other hive @@ -258,6 +239,9 @@ restarts, so the first boot after the switch already has both halves. device that minted it; removing the registration token touches no device, no account and no session. `login_with_password` stays on, so the password fallback is still there too. +- **The per-agent sweep honours existing token files.** It skips any + agent that already has a `matrix-token`, so it re-registers no account + and displaces no session. - **The sender account may already be an admin** on such a hive (it won the first-user grant when the hive was new). Nothing here demotes it; the homeserver no longer promotes it, so a hive built fresh has an diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 4be3c1e3..f05efba3 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,35 +54,20 @@ strategy for every credential, including the mTLS leaf. -| 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 | +| 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 | -**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 7ec4afa4..06ce86e9 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 a matrix account for a person or other non-agent `` and print its access token to stdout +* `create-user` — Create or refresh the matrix account + access token for `` * `sync-admin` — Provision (or re-provision) the matrix appservice's sender account * `promote-user` — Promote a matrix user to homeserver admin * `reset-password` — Reset a matrix user's password via the admin API @@ -152,15 +152,15 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ## `hivectl matrix create-user` -Create a matrix account for a person or other non-agent `` and print its access token to stdout. +Create or refresh the matrix account + access token for ``. -Refuses an agent's name: its account comes from the swarm (`swarm-controller` creates it and stores its token where the agent reads it). Set a password to enable matrix web-client login (otherwise it uses a random throwaway). +For an existing agent, persists the token to its state dir; for a human/other account, prints the access token to stdout. Set a password to enable matrix web-client login (otherwise it uses a random throwaway). **Usage:** `hivectl matrix create-user [OPTIONS] ` ###### **Arguments:** -* `` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc +* `` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc ###### **Options:** diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 3cb96b87..358ed60a 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -50,6 +50,7 @@ Manual entry to the same idempotent matrix provisioning flow running (`services.hyperhive.deploy.matrix.enable = true`). ```bash +hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara --password hunter2 # set a client-login password hivectl matrix sync-admin # provision / refresh the appservice's sender account @@ -59,9 +60,9 @@ hivectl matrix invite mara # invite a user to the hive Space hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias ``` -- `create-user`: for people and other non-agent accounts. It refuses - an agent's name: `swarm-controller` creates an agent's account and - stores its token where the agent's daemon reads it. +- `create-user`: for agents, persists the `access_token` to + `/matrix-token`. Skips registration when the file already + exists — delete it first to force re-registration. - `sync-admin`: ensures this hive's appservice sender account (`@hive-:`, 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 14f5236d..cf5ea334 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. 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. +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. `main` is present exactly when that URL is non-null, which is the whole @@ -122,10 +122,9 @@ directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort` `{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects to the stable URL every turn instead of respawning a stdio child. -Silently exits when the primary account has no token yet; the -`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file -appears, and, on an agent with a store, a timer restarts it while it's -down so it re-reads a token the swarm minted into the store. +Silently exits when `/matrix-token` is absent (account not yet +provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts +it the moment hive-c0re provisions the token. Incoming room events wake the agent via `AgentRequest::Wake` with `from: "matrix"`. The wake body format depends on the unread state: diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 85385c12..4d3da7d0 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 work only — no nix +/// means the profile swap succeeded. Store/forge/matrix work only — no nix /// build (build-slot-exempt); the agent lease taken at `Swap` is still held /// (the whole chain up to `Reconcile` is one agent's subgraph). async fn run_rebuild_bookkeeping(coord: &Arc, 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 sync on every successful rebuild so the rebuild path is - // equivalent to the startup sweep: tokens, config-repo mirror, meta - // access all recover without a hive-c0re restart. (No matrix step: the - // swarm mints an agent's matrix account, not this hive.) + // Full forge + matrix sync on every successful rebuild so the rebuild + // path is equivalent to the startup sweep: tokens, config-repo mirror, + // meta access all recover without a hive-c0re restart. crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await; + crate::matrix::sync_agent_standalone(name).await; // Wake the agent on its next turn so claude sees a "you were rebuilt" // hint; rescan so dashboards drop the "needs update" chip; lock bump → // meta-inputs re-render. diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4440c4d1..59f222ab 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -20,6 +20,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use reqwest::StatusCode; +use crate::coordinator::Coordinator; + /// Client-server API base this daemon provisions against, from /// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from /// `services.hyperhive.swarm.matrix.apiUrl`). @@ -124,15 +126,45 @@ pub fn sender_token_path() -> PathBuf { crate::paths::matrix_sender_token() } -/// Password file for a matrix account this hive holds a password for: its own -/// `@hive-:` account, or one reset through the admin room. Stored OUTSIDE -/// the purgeable `agent_state_root` tree so it survives `destroy --purge`. +/// Token file inside the agent's bind-mounted state dir (visible as +/// `/state/matrix-token` from inside the container). +fn token_path(name: &hive_types::Ident) -> PathBuf { + Coordinator::agent_notes_dir(name).join("matrix-token") +} + +/// Whether an agent's token file is present: a non-empty regular file. +/// Decided from metadata alone, because hive-priv writes the file 0600 +/// and owned by the agent, so `hive-core` cannot read it but can stat it +/// through the 0755 state dir. A check that reads the file always +/// fails here and makes every sweep re-mint the token. +fn token_file_present(path: &std::path::Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > 0) +} + +/// Password file for the agent's matrix account. Stored OUTSIDE the +/// purgeable `agent_state_root` tree so it survives `destroy --purge` +/// and allows re-login recovery when the same agent name is re-spawned. /// /// Path: `/var/lib/hyperhive/matrix/creds/-password` +/// +/// The token file lives inside the agent's bind-mounted state dir (under +/// `agent_notes_dir`) so the agent container can read it; the password +/// file is host-side only (agents never log in by password — they use +/// the access token exclusively) and belongs with other hive-c0re +/// credential state, not inside the purgeable per-agent tree. fn password_path(name: &str) -> PathBuf { crate::paths::matrix_creds_dir().join(format!("{name}-password")) } +/// Legacy password path (inside the old purgeable `agent_notes_dir`). +/// Used only during the one-time migration in [`ensure_user_for`] to +/// move credentials from old deployments to the new location. Safe to +/// call after `destroy --purge` — the path will simply not exist and +/// the migration is a no-op. +fn legacy_password_path(name: &hive_types::Ident) -> PathBuf { + Coordinator::agent_notes_dir(name).join("matrix-password") +} + /// Host path where the hive Matrix Space room ID is persisted. /// Outside every purgeable path — not deleted by `destroy --purge`. #[must_use] @@ -287,8 +319,8 @@ async fn register_user( "type": "m.login.application_service", "username": localpart, "password": password, - // device_id stays stable across re-runs so a re-mint doesn't - // strand orphan devices in tuwunel. + // device_id stays stable across re-runs of ensure_user_for so + // a re-mint doesn't strand orphan devices in tuwunel. "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, @@ -384,6 +416,32 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re extract_access_token(&json) } +/// Auto-recovery helper: reset a user's matrix password through the admin +/// room when the locally stored password is missing. Returns the new +/// password (already persisted to [`password_path`]) on success. +/// +/// ⚠️ Needs an **admin sender**, which `@hive-:` 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 // --------------------------------------------------------------------------- @@ -653,13 +711,162 @@ async fn admin_room_reset_password( }) } +/// Ensure `name` has a matrix user + token file on the local +/// homeserver. Skips provisioning entirely if the token file already +/// exists (treating a present token as proof the account is good). +/// To force re-provisioning, delete the token file. +/// +/// When registration fails with `M_USER_IN_USE` (account exists in the +/// homeserver but the token file was deleted) this falls back to +/// `m.login.password` using the persisted `matrix-password` file. If +/// that file is also missing, recovery requires manual intervention: +/// `hivectl matrix create-user --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. The token is **not** -/// persisted to disk — the caller is responsible for storing it. Used by -/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real +/// and return the freshly-minted access token. Unlike [`ensure_user_for`], +/// the token is **not** persisted to disk — the caller is responsible +/// for storing it. Used by `hivectl matrix create-user` for human +/// (non-agent) accounts so we don't create stray +/// `/var/lib/hyperhive/agents//` directories for users that +/// aren't agents. For operator accounts the caller passes a real /// password so the operator can `m.login.password` into matrix web -/// clients afterwards; without one the caller passes -/// [`random_password`]. +/// clients afterwards; for headless agent re-provisioning the caller +/// can pass [`random_password`] to keep the existing throwaway +/// behaviour. /// /// **Not idempotent**: the matrix `/register` endpoint returns /// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart, @@ -675,6 +882,44 @@ pub async fn provision_user_token( register_user(client, name, as_token, password).await } +/// Per-agent matrix sync: ensure the agent has a matrix account + token. +/// All operations are idempotent; failures are logged as warnings but +/// don't abort the caller. +pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) { + if let Err(e) = ensure_user_for(client, name, as_token).await { + tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); + } +} + +/// Standalone per-agent sync that handles its own setup: checks if +/// hive-matrix is present, reads the appservice token, and builds +/// an HTTP client before delegating to [`sync_agent`]. Mirrors the +/// setup in [`ensure_all`] so the rebuild path and the startup sweep +/// stay equivalent. No-op when the matrix container is absent. +pub async fn sync_agent_standalone(name: &str) { + if !is_present() { + return; + } + let as_token = match read_appservice_token() { + Ok(t) => t, + Err(e) => { + tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed"); + return; + } + }; + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed"); + return; + } + }; + sync_agent(&client, name, &as_token).await; +} + /// Ensure the hive's `@hive-:` matrix user exists and that its access token is /// persisted at [`sender_token_path()`]. /// @@ -934,7 +1179,8 @@ pub async fn promote_user_to_admin( /// /// Sends `!admin users reset-password @:` 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. +/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login +/// on the next provisioning sweep. /// /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and /// the same consequence: `@hive-:` is an ordinary account with no admin @@ -1628,11 +1874,10 @@ async fn resolve_room_alias( .ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}")) } -/// Make sure the hive's own `@hive-:` account exists, then provision -/// the hive Space and chat room and invite every agent container to both. -/// Agents' own accounts are not created here: `swarm-controller` mints them -/// with the swarm's appservice token. Called at hive-c0re startup, alongside -/// `forge::ensure_all`, and then periodically (see the caller in `main.rs`). No-op when the +/// Sweep every existing container (manager + sub-agents) and ensure +/// each has a matrix user + token on the local homeserver. Called at +/// hive-c0re startup, alongside `forge::ensure_all`, and then +/// periodically (see the caller in `main.rs`). No-op when the /// hive-matrix container isn't running. Per-step failures are logged /// but don't abort the sweep. /// @@ -1648,7 +1893,9 @@ pub async fn ensure_all() -> bool { } let mut ok = true; // Loud and non-destructive: with no appservice token this sweep can - // create nothing, so it does nothing. + // create nothing, so it does nothing. Agents that already hold a + // token keep using it — their accounts and sessions are untouched by + // anything in here. let as_token = match read_appservice_token() { Ok(t) => t, Err(e) => { @@ -1656,7 +1903,8 @@ pub async fn ensure_all() -> bool { return false; } }; - // One HTTP client for the whole sweep. + // One HTTP client for the whole sweep — connection pool is + // reused across agents. let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() @@ -1684,6 +1932,7 @@ pub async fn ensure_all() -> bool { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; + sync_agent(&client, name, &as_token).await; agent_names.push(name.to_owned()); } @@ -1852,4 +2101,30 @@ mod tests { let err = extract_access_token(&body).unwrap_err(); assert!(err.to_string().contains("missing access_token")); } + + #[test] + fn token_file_present_only_for_a_non_empty_regular_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let token = dir.path().join("matrix-token"); + assert!(!token_file_present(&token), "missing file"); + std::fs::write(&token, "").unwrap(); + assert!(!token_file_present(&token), "empty file"); + std::fs::write(&token, "tok\n").unwrap(); + assert!(token_file_present(&token), "non-empty file"); + assert!(!token_file_present(dir.path()), "directory"); + } + + /// The sweep runs as `hive-core`, which cannot read the agent-owned + /// 0600 token file. `chmod 000` does not stop root, so this test uses + /// content that `read_to_string` rejects instead: the predicate must + /// still report the file as present, which holds only if it never + /// reads the content. + #[test] + fn token_file_present_does_not_read_the_content() { + let dir = tempfile::tempdir().expect("tempdir"); + let token = dir.path().join("matrix-token"); + std::fs::write(&token, [0xff, 0xfe]).unwrap(); + assert!(std::fs::read_to_string(&token).is_err()); + assert!(token_file_present(&token)); + } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 411b616d..8b1ae675 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -539,37 +539,51 @@ 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(); - 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) + if agent_exists(name)? { + if password.is_some() { + // Agents auth by access_token, never by password — the + // boot-sweep provisioning path doesn't accept one. Refuse + // rather than silently dropping it. + anyhow::bail!( + "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" + ); + } + crate::matrix::ensure_user_for(&client, name.as_str(), &as_token) .await .with_context(|| format!("matrix create-user {name}"))?; - 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()); + let path = Coordinator::agent_notes_dir(name).join("matrix-token"); + out.push(format!("matrix: provisioned agent user '{name}'")); + out.push(format!("token persisted at: {}", path.display())); } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); + 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(), + ); + } } Ok(HostResponse::messages(out)) } diff --git a/hive-matrix-mcp/Cargo.toml b/hive-matrix-mcp/Cargo.toml index 1693d81a..5b83916d 100644 --- a/hive-matrix-mcp/Cargo.toml +++ b/hive-matrix-mcp/Cargo.toml @@ -21,7 +21,6 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -swarm-secret-client.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 86eaf7c7..f77619fe 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -1,15 +1,13 @@ -//! matrix-sdk `Client` setup for the daemon: take the per-agent access -//! token [`crate::credential`] resolved, probe `whoami` to recover the -//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk -//! session, return the Client ready to start sync. -//! -//! 🩸 The token arrives as a [`Token`] and never as a path this module -//! reads — where it came from is [`crate::credential`]'s question, and the -//! only thing said about it here is its [`crate::credential::Origin`]. +//! matrix-sdk `Client` setup for the daemon: read the per-agent access +//! token from the state-dir file `hive-c0re::matrix::ensure_user_for` +//! wrote, probe `whoami` to recover the agent's matrix `user_id` + +//! `device_id`, restore the matrix-sdk session, return the Client ready +//! to start sync. //! //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's -//! ccc.de connection): for the swarm's tuwunel the swarm already minted the -//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed +//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted +//! the token + user/device as the hive's appservice and handed us the +//! bearer in a file. matrix-sdk's `restore_session` with a constructed //! `MatrixSession` skips the login flow entirely. //! //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. @@ -36,12 +34,10 @@ use matrix_sdk::{ use serde::Deserialize; use tokio::fs; -use crate::credential::Token; - /// Sentinel returned when `build_and_restore` detects that the token is /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been -/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the -/// account needs re-provisioning. +/// removed from disk. Callers should NOT retry — the account needs +/// re-provisioning by hive-c0re. /// /// Distinct from the general `anyhow::Error` path so callers can use /// `err.downcast_ref::()` to distinguish "retry @@ -68,53 +64,63 @@ struct WhoamiResponse { } /// Build + restore a matrix-sdk `Client` for the per-agent bearer -/// `token`. The Client points at `homeserver`, persists its sqlite cache -/// under `state_dir`, and is ready for sync once returned. +/// token at `token_file`. The Client points at `homeserver`, persists +/// its sqlite cache under `state_dir`, and is ready for sync once +/// returned. /// /// Steps: -/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with +/// 1. Read the bearer token from `token_file` (trim trailing whitespace). +/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with /// the bearer — this gives us back the matrix `user_id` + -/// `device_id` (the registration response had them but only the -/// token was kept; whoami is the cheapest recovery path +/// `device_id` (the registration response had them but hive-c0re +/// only persisted the token; whoami is the cheapest recovery path /// and avoids matrix-sdk's circular requirement of needing a /// session to call whoami). -/// 2. Build the real Client with the sqlite store + `restore_session` +/// 3. Build the real Client with the sqlite store + `restore_session` /// using a synthetic `MatrixSession`. /// /// # Errors /// -/// Returns an error if the `whoami` request fails, or if the matrix-sdk -/// client fails to build or restore the session. +/// Returns an error if the token file is missing or empty, if the +/// `whoami` request fails, or if the matrix-sdk client fails to build +/// or restore the session. pub async fn build_and_restore( homeserver: &str, - token: &Token, + token_file: &Path, state_dir: &Path, is_primary: bool, ) -> Result { - let (user_id, device_id) = match whoami(homeserver, token.expose()).await { + let token = fs::read_to_string(token_file) + .await + .with_context(|| format!("read matrix token from {}", token_file.display()))? + .trim() + .to_owned(); + if token.is_empty() { + return Err(anyhow!("matrix token at {} is empty", token_file.display())); + } + + let (user_id, device_id) = match whoami(homeserver, &token).await { Ok(ids) => ids, Err(e) => { let msg = format!("{e:#}"); if msg.contains("M_UNKNOWN_TOKEN") { // Homeserver rejected our token — stale session after a homeserver - // state wipe or token expiry. Discard it so we don't loop on it - // (which for a store-held credential is a log line and nothing - // else — see `Token::discard_stale`). The rest of the handling - // depends on whether this is the primary (hive-internal) account - // or a secondary one, because a single bad secondary token must - // NOT take down the whole daemon (and with it every healthy - // account). + // state wipe or token expiry. Delete the stale token file so we + // don't loop on it. The rest of the handling depends on whether + // this is the primary (hive-internal) account or a secondary one, + // because a single bad secondary token must NOT take down the + // whole daemon (and with it every healthy account). tracing::warn!( - credential = %token.origin(), + path = %token_file.display(), is_primary, - "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" + "matrix token rejected (M_UNKNOWN_TOKEN); removing stale token" ); - token.discard_stale().await; + let _ = fs::remove_file(token_file).await; if is_primary { // Primary: also drop the matrix-sdk sqlite state keyed to the - // now-invalid session, then exit 0; the swarm's backfill - // re-mints the token and systemd restarts us once it - // exists (store re-check timer, or the path watcher). Exit 0 (not + // now-invalid session, then exit 0 so hive-c0re's `ensure_all` + // re-provisions the account and the systemd.paths watcher + // restarts us once the fresh token file appears. Exit 0 (not // Err) keeps systemd's Restart=on-failure from looping; the // call site is before any tasks are spawned so there's // nothing to clean up. @@ -127,13 +133,13 @@ pub async fn build_and_restore( } std::process::exit(0); } - // Secondary: token discarded (so it's cleanly skipped next boot + // Secondary: token removed (so it's cleanly skipped next boot // rather than re-erroring); leave the sdk state in place in case // the operator re-provisions a fresh token for the same device. // Return a PermanentBringUpError so the caller can distinguish // "don't retry" from a transient network/DNS failure. return Err(anyhow::Error::new(PermanentBringUpError( - "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(), + "matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(), ))); } return Err(e); @@ -153,7 +159,7 @@ pub async fn build_and_restore( let session = MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: SessionTokens { - access_token: token.expose().to_owned(), + access_token: token, refresh_token: None, }, }; diff --git a/hive-matrix-mcp/src/credential.rs b/hive-matrix-mcp/src/credential.rs deleted file mode 100644 index 8488ba92..00000000 --- a/hive-matrix-mcp/src/credential.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! The matrix access token as a value this process holds, and the two places -//! it comes from. -//! -//! 🩸 **A secret is a path, not a value.** Nothing here writes the token -//! anywhere, interpolates it into a command, or lets it reach a log line or an -//! error message: [`Token`] has no `Debug` derive, and every message below -//! names an [`Origin`] — which is a path in the store or a path on disk, never -//! the bytes at either. -//! -//! The store read is this agent reading **its own** credential under **its own -//! identity, from inside its own container** — the path -//! `swarm/agents//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, and systemd restarts us once a token exists - // (the path-watcher for a file, the re-check timer for the store). + // useful — exit 0 like the legacy single-account path so the + // systemd path-watcher restarts us when the token appears. Ok(None) if is_primary => { tracing::warn!( account = %cfg.name, "primary matrix account has no token yet; exiting cleanly \ - (systemd restarts us once one exists)" + (systemd restarts us when hive-c0re provisions it)" ); return Ok(()); } @@ -262,46 +259,6 @@ async fn bring_up_secondary_with_retry( None } -/// Resolve `cfg`'s access token, preferring the copy this agent can fetch -/// itself. -/// -/// 🏛️ The store read is the point: the credential at -/// `swarm/agents//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)` @@ -323,19 +280,23 @@ async fn bring_up_account( ); return Ok(None); }; - let Some(token) = account_token(cfg).await? else { + if !tokio::fs::try_exists(&cfg.token_file) + .await + .unwrap_or(false) + { return Ok(None); - }; + } tracing::info!( account = %cfg.name, homeserver, - credential = %token.origin(), + token_file = %cfg.token_file.display(), state_dir = %cfg.state_dir.display(), "bringing up matrix account" ); - let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary) - .await - .with_context(|| format!("build matrix client for account {}", cfg.name))?; + let client = + client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) + .await + .with_context(|| format!("build matrix client for account {}", cfg.name))?; // Best-effort: sync the agent icon to this account's matrix avatar over // 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 8847f41f..98257c0f 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`, -/// where a hive used to write the `main` account's token (the daemon now reads -/// it from the store first; see `main.rs`'s `account_token`). +/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent +/// account provisioning. #[must_use] pub fn token_file() -> PathBuf { if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") { diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index f94da8cf..42c2b6fd 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 a matrix account for a person or other non-agent `` and - /// print its access token to stdout. + /// Create or refresh the matrix account + access token for ``. /// - /// Refuses an agent's name: its account comes from the swarm - /// (`swarm-controller` creates it and stores its token where the agent - /// reads it). Set a password to enable matrix web-client login - /// (otherwise it uses a random throwaway). + /// For an existing agent, persists the token to its state dir; for a + /// human/other account, prints the access token to stdout. Set a + /// password to enable matrix web-client login (otherwise it uses a + /// random throwaway). CreateUser { - /// Matrix localpart of a non-agent account — `mara`, `damocles`, etc. + /// Matrix localpart. For agents: the container/agent name. + /// For humans: any matrix localpart — `mara`, `damocles`, etc. name: String, /// Set the account password to this string instead of a random /// throwaway. Use this for operator accounts that need to log diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 3dc6af81..7da62f78 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -14,21 +14,12 @@ }: let userName = config.services.hyperhive.agent.user.name; - # This agent's own state dir, where a hive used to write the `main` - # account's token (`matrix-token`, now the store's fallback) and the - # daemon keeps its matrix-sdk store. Shared by the `main` account entry - # below and the path-watcher glob at the bottom of this file. + # This agent's own state dir, where hive-c0re provisions the + # hive-internal account's token (`matrix-token`) and the daemon keeps + # its matrix-sdk store. Shared by the `main` account entry below and + # the path-watcher glob at the bottom of this file. stateDir = "/agents/${userName}/state"; accounts = config.services.hyperhive.agent.matrixAccounts; - # This agent's own identity at the swarm secret store (./bao.nix). The daemon - # reads each account's token from the store itself, `main` included (the - # swarm mints that one), so the store's coordinates belong on its unit. The - # same three credential ids ./bao.nix and ./forge-token.nix load. - baoCfg = config.services.hyperhive.agent.bao; - storeConfigured = baoCfg.addr != null; - certCredential = "hive-agent-bao-cert"; - keyCredential = "hive-agent-bao-key"; - serverCaCredential = "hive-agent-bao-server-ca"; # **The enable signal.** Matrix is on for this agent exactly when it has at # least one account, because an account is the only thing the daemon has to # do: no account, no Client, no sync, no tool surface worth injecting. @@ -99,9 +90,8 @@ in Path to this account's bearer-token file. The daemon reads the token from here to restore the matrix session; how the file gets populated is the provisioner's concern - (for `main`, a file only when the store has no token: the - swarm mints `main` into the store; an operator-supplied - secret for an external one). The daemon + (hive-c0re for the hive-internal `main` account, an + operator-supplied secret for an external one). The daemon skips an extra account whose token file is absent, and exits cleanly to wait on the path watcher when `main`'s is. ''; @@ -153,7 +143,7 @@ in homeserver (`homeserver`, else `services.hyperhive.agent.matrix.url`). The daemon auto-skips an account whose URL or token file is missing, and a `systemd.paths` - watcher restarts it the moment a token file appears + watcher restarts it the moment hive-c0re provisions the token (same path-trigger shape as `forge-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, @@ -183,8 +173,8 @@ in like any other, so it shows up in the account list --- what you add here are the *further* accounts (e.g. an external public-matrix account). Its `tokenFile` stays pinned to - `/matrix-token` (an assertion; the file the daemon falls back - to when the store has no `main` token), and the + `/matrix-token` (an assertion; that is the one path + hive-c0re provisions the hive-internal token to), and the dashboard's link-account route refuses to create an account named `main` --- the entry belongs to the module, not to a provisioner. @@ -225,8 +215,8 @@ in # `main` is not a forbidden key --- this module declares it # itself (see the `matrixAccounts.main` definition below), so the # name must be allowed. What stays rejected is retargeting *its - # token file*: the daemon's file fallback for `main` is - # `/matrix-token` and nothing else, so an override there + # token file*: hive-c0re writes the hive-internal account's token + # to `/matrix-token` and nowhere else, so an override there # is an account that evaluates fine and then never restores. The # other two fields are free to override (a `mkDefault` each). # @@ -237,8 +227,8 @@ in assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token"; message = "services.hyperhive.agent.matrixAccounts.main.tokenFile must stay " - + "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the " - + "main account's token. Declare a separate account instead of " + + "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the " + + "hive-internal account's token. Declare a separate account instead of " + "pointing `main` elsewhere."; } # Token files must land at the `matrix-token*` name the daemon @@ -270,7 +260,7 @@ in # so the one it always has belongs in it. `mkDefault` per field so an # operator can retarget e.g. the homeserver without a # conflicting-definition error (the token file is pinned by an - # assertion above, since the daemon's fallback reads that path). + # assertion above, since hive-c0re owns that path). # # ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled` # from being trivially true for every agent in the hive. A `main` with no @@ -319,20 +309,6 @@ in HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; RUST_LOG = "info"; } - # The store's coordinates and which agent this is, so the daemon reads - # `swarm/agents//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 @@ -381,8 +357,7 @@ in # `on-failure`, not `always`: the daemon deliberately exits 0 # (a clean, non-failure exit) when no token is provisioned yet # (see the module doc above) — the `systemd.paths` watcher - # below re-fires it the moment a token file appears (the timer - # above, for a token in the store), + # below re-fires it the moment hive-c0re provisions one, # instead of `always` busy-looping every `RestartSec` until # then. Once a token exists this is no different from # `hive-bash-daemon`'s reasoning (a down window loses the MCP @@ -392,33 +367,11 @@ in RestartSec = 5; User = userName; Group = userName; - } - # This agent's own certificate, imported by id so systemd materialises it - # under this unit's `User=` — the same bare form ./forge-token.nix uses. - // lib.optionalAttrs storeConfigured { - LoadCredential = [ - certCredential - keyCredential - serverCaCredential - ]; }; }; - # Re-start the daemon while it is down, when its token lives in the store. - # The path unit below only sees files, and a token the swarm mints or - # replaces in the store changes no file: a daemon that exited on a missing - # or replaced token would otherwise stay down until the container restarts. - # Relative to the daemon's last exit, so it never fires while the daemon - # runs; five minutes is the swarm's own re-mint cadence. - systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) { - description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store"; - wantedBy = [ "timers.target" ]; - timerConfig.OnUnitInactiveSec = "5min"; - }; - - # Re-fire the daemon when the matrix token appears (a token - # file: an extra account the hive delivers, or a `main` from before the - # swarm minted it). Without this + # Re-fire the daemon when the matrix token appears (hive-c0re + # provisions it after agent containers come up). Without this # the daemon would exit 0 silently on first boot and the MCP # would have no backend until next restart. See # `docs/agent-lifecycle/persistence.md` (same section as above). diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 276d8728..0f4fc3cb 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -172,26 +172,6 @@ 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 @@ -1324,27 +1304,21 @@ in # `Services::start()`, before the listener accepts anything. appservice_dir = appserviceCredentialDir; - } - # 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; - } - // { + # 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. # Server-side E2EE is opt-in (default off); the agent matrix # client always supports decryption regardless. allow_encryption = cfg.allowEncryption; @@ -1413,13 +1387,7 @@ 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 @@ -1481,61 +1449,6 @@ 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 372c088a..d2fa786e 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -194,10 +194,6 @@ 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"] @@ -218,10 +214,6 @@ 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 @@ -286,18 +278,10 @@ 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 @@ -311,12 +295,6 @@ 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 6c56e9c3..de0b9838 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -227,9 +227,11 @@ let SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; }; - # 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. + # 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. matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) { SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl; }; @@ -559,24 +561,19 @@ in matrixHomeserverUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - # 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}"''; + default = null; example = "https://matrix.example.org"; description = '' - 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 + 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. - That route does not consult it yet. + + `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. ''; }; diff --git a/nix/module-eval/agent-matrix.nix b/nix/module-eval/agent-matrix.nix index 529a8766..2d25857d 100644 --- a/nix/module-eval/agent-matrix.nix +++ b/nix/module-eval/agent-matrix.nix @@ -18,7 +18,6 @@ let ; }) agent - agentWith runGroup ; @@ -46,15 +45,6 @@ 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 @@ -112,69 +102,6 @@ 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 da8883e9..22e1e573 100644 --- a/nix/module-eval/bao-controller.nix +++ b/nix/module-eval/bao-controller.nix @@ -59,15 +59,6 @@ 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 c3c7cfa7..7726dc2a 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -334,18 +334,12 @@ 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. - # - # 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"; + name = "matrix-ctl's grant is one hive's sender token path and 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) @@ -753,14 +747,6 @@ 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 45ab3e46..19a0da5f 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -392,64 +392,18 @@ let && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); } { - # 🩸 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"; + # 🩸 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"; ok = let g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global; in - 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); + !(g ? admin_execute) || g.admin_execute == [ ]; } { # 🩸 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 1e4dcf71..412c9fa5 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -96,9 +96,6 @@ 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 35623a0a..3af90807 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -113,14 +113,6 @@ 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`. @@ -147,7 +139,6 @@ 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(), } @@ -166,8 +157,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } - | SwarmNodeKind::MintAgentForgeToken { agent } - | SwarmNodeKind::MintAgentMatrixAccount { agent } => { + | SwarmNodeKind::MintAgentForgeToken { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } @@ -219,11 +209,6 @@ 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. @@ -319,13 +304,21 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, - SwarmNodeKind::MintAgentIdentity { hive, agent } => { - mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await - } + 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::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 \ @@ -345,29 +338,6 @@ 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( @@ -389,28 +359,6 @@ 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 @@ -1565,13 +1513,6 @@ 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 @@ -1612,11 +1553,6 @@ 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()] } @@ -1859,57 +1795,6 @@ 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 @@ -2282,14 +2167,12 @@ 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 @@ -2961,7 +2844,6 @@ 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| { @@ -3016,7 +2898,6 @@ 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| { @@ -3071,7 +2952,6 @@ 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| { @@ -3124,7 +3004,6 @@ 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| { @@ -3363,92 +3242,6 @@ 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 59ebb0da..a9163382 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -1,13 +1,5 @@ -//! 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. +//! 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. //! //! 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 @@ -39,8 +31,6 @@ use utoipa::ToSchema; use super::{AppState, error_problem, swarm_hive}; -pub mod agent_token; - fn default_mode() -> String { "token".to_owned() } @@ -51,9 +41,9 @@ fn default_mode() -> String { /// when a caller omits one. Read via [`configured_default_homeserver`], not /// directly — see that fn's doc. /// -/// 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. +/// 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. pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL"; /// `caller`'s own homeserver, or `default` when the caller left it unset. @@ -260,11 +250,11 @@ pub async fn put_matrix_account( Ok(Json(PutMatrixAccountResponse { user_id })) } -/// 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. +/// 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. fn is_reserved_account(account: &str) -> bool { - account == agent_token::ACCOUNT + account == "main" } /// 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 deleted file mode 100644 index 4240b6bc..00000000 --- a/swarm-controller/src/matrix_account/agent_token.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! 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 deleted file mode 100644 index 4d8bd38b..00000000 --- a/swarm-matrix-client/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index 36a344bd..00000000 --- a/swarm-matrix-client/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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/Cargo.toml b/swarm-matrix-ctl/Cargo.toml index 155bf71a..3d308778 100644 --- a/swarm-matrix-ctl/Cargo.toml +++ b/swarm-matrix-ctl/Cargo.toml @@ -14,10 +14,8 @@ 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 deleted file mode 100644 index da78df21..00000000 --- a/swarm-matrix-ctl/src/appservice.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! `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-client/src/lib.rs b/swarm-matrix-ctl/src/homeserver.rs similarity index 70% rename from swarm-matrix-client/src/lib.rs rename to swarm-matrix-ctl/src/homeserver.rs index c7c4c4fa..05ac19e0 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-ctl/src/homeserver.rs @@ -1,6 +1,4 @@ -//! 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. +//! The two calls the mint ladder is made of, against the homeserver next door. //! //! 🩸 **Every error in this module is built from the response's `status` and //! its `errcode`, never its body.** A successful `/register` or `/login` body @@ -10,8 +8,8 @@ use anyhow::{Context, Result, bail}; -/// Every call here is one client-server API round trip; a slow one is a broken -/// one. +/// Client-server API calls are one round trip each against a homeserver in the +/// same netns; a slow one is a broken one. const TIMEOUT_SECS: u64 = 10; /// Bytes of the throwaway password `/register` is given. @@ -23,11 +21,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 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. +/// 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. pub enum Registered { /// A fresh account, and the access token minted with it. Token(String), @@ -70,7 +68,7 @@ pub async fn register( // request carries the as_token. "type": "m.login.application_service", "username": localpart, - "password": random_hex(PASSWORD_BYTES)?, + "password": random_password()?, "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), "inhibit_login": false, @@ -142,52 +140,6 @@ 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}") @@ -243,18 +195,18 @@ fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { } } -/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s -/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints. +/// A throwaway password for [`register`], as hex. /// -/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is -/// already the source any such crate would reach for here. +/// 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. /// /// # Errors /// When `/dev/urandom` cannot be read. -pub fn random_hex(bytes: usize) -> Result { +fn random_password() -> Result { use std::io::Read as _; - let mut buf = vec![0u8; bytes]; + let mut buf = [0u8; PASSWORD_BYTES]; std::fs::File::open("/dev/urandom") .context("opening /dev/urandom")? .read_exact(&mut buf) @@ -323,48 +275,11 @@ 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_hex(PASSWORD_BYTES).expect("/dev/urandom is readable"); + let pw = random_password().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/src/main.rs b/swarm-matrix-ctl/src/main.rs index 9a734195..0008e919 100644 --- a/swarm-matrix-ctl/src/main.rs +++ b/swarm-matrix-ctl/src/main.rs @@ -7,19 +7,18 @@ //! identity plumbing to add one action, so the next thing that has to run in //! here is a verb below, not a new crate. //! -//! [`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`. +//! Today that is one verb, [`mint`]: publish the appservice sender account's +//! homeserver access token to the swarm's secret store, once. //! //! 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 `swarm_matrix_client`'s module doc for the same rule +//! logs is the store path; see `homeserver`'s module doc for the same rule //! applied to error messages. -mod appservice; +mod homeserver; mod mint; mod registration; @@ -45,20 +44,6 @@ 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] @@ -72,8 +57,6 @@ 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, } } @@ -97,25 +80,6 @@ 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 465b49a6..a72f848b 100644 --- a/swarm-matrix-ctl/src/mint.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -21,9 +21,7 @@ use swarm_secret_client::{ matrix, }; -use swarm_matrix_client as homeserver; - -use crate::registration; +use crate::{homeserver, 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 352546cf..beb97837 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -93,28 +93,6 @@ 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 @@ -237,39 +215,6 @@ 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,