From 2776e121e5014485fbc83606fa07b8aedbdc7b31 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 00:25:06 +0200 Subject: [PATCH] swarm-controller: mint each agent's matrix account with the swarm's token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `MintAgentMatrixAccount` node creates the agent's account on the swarm's homeserver with the swarm appservice token, stores its token at `swarm/agents//matrix/main`, and reads it back with whoami before reporting success. It is a root of agent creation, `after_any` into the deploy, and a five-minute backfill over every agent with a store identity queues the same node — the shape of the forge-token mint. The decision reads the stored token back rather than only checking that one is stored: the swarm and a hive both pin the device `hyperhive-`, so each login replaces the other's token. A failed read plans nothing, so an outage never rotates every agent's token. `matrixHomeserverUrl` now defaults to the swarm's `chat.` vhost, since the mint is what consults it. --- Cargo.lock | 1 + nix/host-modules/swarm-controller.nix | 27 +- nix/module-eval/bao-controller.nix | 9 + swarm-controller/Cargo.toml | 3 + swarm-controller/src/main.rs | 237 +++++++++++- swarm-controller/src/matrix_account.rs | 28 +- .../src/matrix_account/agent_token.rs | 357 ++++++++++++++++++ 7 files changed, 626 insertions(+), 36 deletions(-) create mode 100644 swarm-controller/src/matrix_account/agent_token.rs diff --git a/Cargo.lock b/Cargo.lock index cf431442..802740dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4913,6 +4913,7 @@ dependencies = [ "sha2 0.11.0", "strum", "swarm-authelia-bridge-sock", + "swarm-matrix-client", "swarm-queue-client", "swarm-secret-client", "time", diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index de0b9838..6c56e9c3 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -227,11 +227,9 @@ let SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; }; - # Swarm-wide default for `PUT .../matrix-accounts/{account}`'s own - # `homeserver` field, for a request that omits one. Same shape as - # `authBridgeEnv` above: genuinely optional, gated on the option - # resolving rather than assumed. Not read by anything yet — see the - # option's own description. + # The swarm's homeserver: where `MintAgentMatrixAccount` creates each + # agent's own account. Gated on the option resolving: a swarm with no + # domain has no homeserver URL, and the mint node then fails by name. matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) { SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl; }; @@ -561,19 +559,24 @@ in matrixHomeserverUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = null; + # The same `chat.` ./hive-matrix.nix serves its vhost on + # (`gatewayHost`): a swarm runs one homeserver. + default = if swarmDomain == null then null else "https://chat.${swarmDomain}"; + defaultText = lib.literalExpression ''"https://chat.''${services.hyperhive.swarm.domain}"''; example = "https://matrix.example.org"; description = '' - Swarm-wide default homeserver for `PUT + Client-server API base of the swarm's homeserver. The controller + creates each agent's own matrix account there, with the swarm's + appservice token, and stores its token where the agent reads it. + `null` leaves agents with no matrix account: the mint node fails, + naming this option's variable, and the agent is deployed anyway. + + Also the swarm-wide default homeserver for `PUT .../matrix-accounts/{account}` requests that omit their own `homeserver` — see that route's own doc comment (`swarm-controller/src/matrix_account.rs`) for why the field is optional in token mode and what omitting it currently resolves to. - - `null` (the default) leaves that per-request resolution exactly as - it is today. **Not yet consulted by the route at all**: this option - only exists to carry the value in, ahead of the route being taught - to fall back to it. + That route does not consult it yet. ''; }; diff --git a/nix/module-eval/bao-controller.nix b/nix/module-eval/bao-controller.nix index 22e1e573..da8883e9 100644 --- a/nix/module-eval/bao-controller.nix +++ b/nix/module-eval/bao-controller.nix @@ -59,6 +59,15 @@ let baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or ""); cases = [ + { + # No hive mints an agent's matrix account any more, so a controller that + # did not know the homeserver would create every agent without one. The + # default is the swarm's own `chat.` vhost, with no operator setting. + name = "the controller is told the swarm's homeserver by default"; + ok = + controllerNoStore.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_HOMESERVER_URL + or null == "https://chat.t.local"; + } { # Nothing asserted the PKI script before this, so a third leaf could be # added to it and every case still passed — measured, not assumed: the diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 412c9fa5..1e4dcf71 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -96,6 +96,9 @@ swarm-queue-client = { workspace = true, features = ["kv"] } # name and the object's shape are agreements between the two ends, and a # second spelling here would be a store this hive could not read. swarm-secret-client.workspace = true +# The appservice calls `matrix_account::agent_token` mints agents' accounts +# with — shared with `swarm-matrix-ctl`, which pins the same device id. +swarm-matrix-client.workspace = true # `agent_identity.rs` signs the per-agent client leaf the store authenticates # an agent container by. The one runtime signer in this tree — every other CA # here is a deploy-time `openssl` oneshot — because this one issues per agent diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 3af90807..35623a0a 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -113,6 +113,14 @@ enum SwarmNodeKind { /// Carries no hive: the token's store path has no hive segment, and the /// agent pulls it from wherever it runs. MintAgentForgeToken { agent: String }, + /// Make sure `agent` holds a live token for its own account on the swarm's + /// homeserver, creating the account with the swarm's appservice token + /// when it does not. See `matrix_account::agent_token` — including why a + /// stored token is read back before anything is minted. + /// + /// Carries no hive: the path it writes has no hive segment, so an agent + /// that moves between hives keeps one matrix identity. + MintAgentMatrixAccount { agent: String }, /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// store, so a freshly created agent does not start driving turns the /// moment it's deployed — the operator has to explicitly flip it to `Up`. @@ -139,6 +147,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), + SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(), SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(), SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), } @@ -157,7 +166,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } - | SwarmNodeKind::MintAgentForgeToken { agent } => { + | SwarmNodeKind::MintAgentForgeToken { agent } + | SwarmNodeKind::MintAgentMatrixAccount { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } @@ -209,6 +219,11 @@ struct WorkerDeps { /// see `wanted_writer`. `None` exactly when no swarm queue is configured /// on this host, same as `queue`. wanted: Option>, + /// Client-server API base of the swarm's homeserver, for the node that + /// mints agents' accounts on it. `None` when + /// `matrix_account::DEFAULT_HOMESERVER_ENV` is unset, which that node + /// **fails** on rather than skips, same as every other field here. + matrix_homeserver: Option>, } /// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be. @@ -304,21 +319,13 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, - SwarmNodeKind::MintAgentIdentity { hive, agent } => match deps.agent_ca { - None => Outcome::Failed( - "no agent certificate authority is configured on this host \ - (SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \ - so this agent has no identity at the swarm secret store" - .to_owned(), - ), - Some(authority) => { - match agent_identity::mint_and_verify(&authority, &agent, &hive).await { - Ok(()) => Outcome::Done, - Err(e) => Outcome::Failed(format!("{e:#}")), - } - } - }, + SwarmNodeKind::MintAgentIdentity { hive, agent } => { + mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await + } SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await, + SwarmNodeKind::MintAgentMatrixAccount { agent } => { + mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await + } SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted { None => Outcome::Failed( "no swarm queue is configured on this host, so no wanted-state \ @@ -338,6 +345,29 @@ async fn run_swarm_node( (builder, outcome) } +/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under +/// `clippy::too_many_lines`. +async fn mint_identity( + authority: Option<&agent_identity::Authority>, + agent: &str, + hive: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + let Some(authority) = authority else { + return Outcome::Failed( + "no agent certificate authority is configured on this host \ + (SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \ + so this agent has no identity at the swarm secret store" + .to_owned(), + ); + }; + match agent_identity::mint_and_verify(authority, agent, hive).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + } +} + /// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under /// `clippy::too_many_lines`. async fn mint_forge_token( @@ -359,6 +389,28 @@ async fn mint_forge_token( } } +/// The `MintAgentMatrixAccount` arm, lifted out for the same reason. +async fn mint_matrix_account( + homeserver: Option<&str>, + agent: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + // Loud on purpose: an agent quietly created without a matrix account is + // an agent nothing can talk to, and no hive mints one any more. + let Some(base) = homeserver else { + return Outcome::Failed(format!( + "no matrix homeserver configured on this host ({} unset), so no agent \ + matrix account can be minted", + matrix_account::DEFAULT_HOMESERVER_ENV + )); + }; + match matrix_account::agent_token::ensure_agent_matrix_account(base, agent).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + } +} + /// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns /// out not to be new: an agent that already has a declaration (other than /// `Destroyed`, which this treats as reusable) is left alone, so a retried @@ -1513,6 +1565,13 @@ fn declare_agent_job( agent: agent.to_owned(), }) .after_ok(create_forge_user); + // The agent's account on the swarm's homeserver. A root of its own: + // creating it needs neither an authelia subject nor a forge user nor a + // repo, and chaining it behind one of those would make an unrelated + // failure look like a matrix failure. + let mint_matrix = b.node(SwarmNodeKind::MintAgentMatrixAccount { + agent: agent.to_owned(), + }); // Declared before the deploy trigger so the pause is visible in the // wanted-state store before the hive brings the container up — see the // node's own doc comment for why "before", not just "eventually". Needs @@ -1553,6 +1612,11 @@ fn declare_agent_job( .after_ok(init_config) .after_any(mint_identity) .after_any(mint_forge_token) + // `after_any` for the same reason: the container reads this token + // rather than producing it, so the deploy must not overtake the mint — + // but a host with no homeserver configured must still create agents, + // and only this node fails, by name. + .after_any(mint_matrix) .after_ok(set_wanted); vec![create_identity.guid()] } @@ -1795,6 +1859,57 @@ fn queue_forge_token_mints( Ok(ids) } +/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes' +/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through +/// here, so a backfilled mint is the same node a new agent gets. +fn queue_matrix_account_mints( + sched: &Mutex>, + agents: Vec, +) -> Result> { + let mut sched = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut ids = Vec::with_capacity(agents.len()); + for agent in agents { + let queued = sched + .insert_job(None, |b| { + vec![ + b.node(SwarmNodeKind::MintAgentMatrixAccount { agent }) + .guid(), + ] + }) + .map_err(|e| anyhow::anyhow!("{e}"))?; + ids.extend(queued); + } + Ok(ids) +} + +/// The homeserver agents' own matrix accounts are minted on. Unset is this +/// controller creating agents with no matrix account, which the mint node +/// reports by name rather than `main` refusing to start. +fn configured_matrix_homeserver() -> Option> { + matrix_account::configured_default_homeserver() + .filter(|v| !v.is_empty()) + .map(Arc::from) +} + +/// Start the matrix-account backfill when a homeserver is configured. Lifted +/// out of `main` for `clippy::too_many_lines`. +fn spawn_matrix_account_backfill( + jobq: &Arc>>, + homeserver: Option>, +) { + let Some(base) = homeserver else { + return; + }; + let sched = Arc::clone(jobq); + matrix_account::agent_token::spawn(base, move |agents| { + if let Err(e) = queue_matrix_account_mints(&sched, agents) { + tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed"); + } + }); +} + /// Check an agent's forge token now, and mint one if it is missing or stale. /// /// The periodic pass (`forge::agent_token::spawn`) does the same every five @@ -2167,12 +2282,14 @@ async fn main() -> Result<()> { queue: status.as_ref().map(|s| s.queue_client()), agent_ca: load_agent_authority(), wanted: wanted_writer(status.as_ref()), + matrix_homeserver: configured_matrix_homeserver(), }; let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))); + spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone()); spawn_jobq_worker(Arc::clone(&jobq), deps); // Bound to a named variable, not `_` — dropping the provider stops its // `PeriodicReader`, so it must live as long as `main` does (which it @@ -2844,6 +2961,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -2898,6 +3016,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -2952,6 +3071,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -3004,6 +3124,7 @@ mod tests { queue: None, agent_ca: None, wanted: None, + matrix_homeserver: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -3242,6 +3363,92 @@ mod tests { assert_eq!(kind.data(1)["agent"], "atlas"); } + #[test] + fn a_matrix_account_node_renders_the_agent() { + use hive_jobq_wire::WireNode as _; + + let kind = SwarmNodeKind::MintAgentMatrixAccount { + agent: "atlas".to_owned(), + }; + assert_eq!(kind.label(), "mint_agent_matrix_account"); + assert_eq!(kind.data(1)["agent"], "atlas"); + } + + /// Agent creation mints the matrix account as a root of its own, and the + /// deploy waits for it without being cancelled by it: a host with no + /// homeserver configured must still deploy the agent. + #[tokio::test] + async fn the_matrix_account_mint_is_a_root_and_does_not_block_the_deploy() { + use hive_jobq_wire::WireNode as _; + + let (state, sched) = state_with_roster(); + let _queued = super::create_agent( + axum::extract::State(state), + axum::Json(super::CreateAgentRequest { + name: "atlas".to_owned(), + hive: "pr1ma".to_owned(), + }), + ) + .await + .expect("a hive in the roster must be accepted"); + + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let graph = guard.graph(); + let find = |label: &str| { + graph + .nodes() + .find(|n| n.payload.label() == label) + .unwrap_or_else(|| panic!("the graph holds a {label} node")) + }; + let mint = find("mint_agent_matrix_account"); + assert!(mint.deps.is_empty(), "the mint waits for nothing"); + let when = find("trigger_deploy") + .deps + .iter() + .find_map(|d| match d { + hive_jobq::Dep::Node { id, when } if *id == mint.id => Some(*when), + _ => None, + }) + .expect("the deploy waits for the matrix mint"); + assert!( + when.accepts(hive_jobq::TerminalState::Failed), + "a host with no homeserver must not cancel the deploy; this edge \ + has to be `after_any`, not `after_ok`" + ); + } + + /// The backfill's queueing: one mint node per agent, nothing else. + #[test] + fn a_queued_matrix_mint_is_one_node_per_agent() { + use hive_jobq_wire::WireNode as _; + + let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + )); + let ids = super::queue_matrix_account_mints(&sched, vec!["a".to_owned(), "b".to_owned()]) + .expect("two jobs insert"); + assert_eq!(ids.len(), 2); + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut agents: Vec = guard + .graph() + .nodes() + .map(|n| { + assert_eq!(n.payload.label(), "mint_agent_matrix_account"); + n.payload.data(n.id.get())["agent"] + .as_str() + .expect("agent is a string") + .to_owned() + }) + .collect(); + agents.sort(); + assert_eq!(agents, ["a", "b"]); + } + /// The manual route and the periodic pass both go through /// `queue_forge_token_mints`, so this is the assertion that a backfilled /// mint is the same pair of nodes agent creation inserts: the forge user, diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index a9163382..59ebb0da 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -1,5 +1,13 @@ -//! Give one agent an external matrix account: put the credential in the -//! swarm's secret store, then tell that agent's hive it is there. +//! An agent's matrix accounts, from this daemon's two sides of them. +//! +//! **The internal one** — [`agent_token`] — is the agent's own `main` account on +//! the swarm's homeserver, minted with the swarm's appservice token and stored +//! where the agent's daemon reads it. See docs/swarm/credentials.md for why +//! `main` is the one name [`put_matrix_account`] refuses to write. +//! +//! **The external one** — [`put_matrix_account`] and everything under it — is +//! an account somewhere else that an operator hands us a credential for: put it +//! in the swarm's secret store, then tell that agent's hive it is there. //! //! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the //! value under its own identity and writes it into the agent's state dir. The @@ -31,6 +39,8 @@ use utoipa::ToSchema; use super::{AppState, error_problem, swarm_hive}; +pub mod agent_token; + fn default_mode() -> String { "token".to_owned() } @@ -41,9 +51,9 @@ fn default_mode() -> String { /// when a caller omits one. Read via [`configured_default_homeserver`], not /// directly — see that fn's doc. /// -/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default` -/// below exists for a later slice of this homeserver-default rollout to -/// call; this one only wires the config through. +/// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet +/// consulted by [`put_matrix_account`]: `homeserver_or_configured_default` +/// below exists for a later slice of this homeserver-default rollout to call. pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL"; /// `caller`'s own homeserver, or `default` when the caller left it unset. @@ -250,11 +260,11 @@ pub async fn put_matrix_account( Ok(Json(PutMatrixAccountResponse { user_id })) } -/// Whether `account` is the hive-internal name every hive declares per -/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment -/// for why this route must never write one. +/// Whether `account` is the agent's own account, which [`agent_token`] mints +/// and `nix/agent-modules/matrix.nix` declares per agent — see the call site's +/// own comment for why this route must never write one. fn is_reserved_account(account: &str) -> bool { - account == "main" + account == agent_token::ACCOUNT } /// Token-mode's only requirement: a token was actually given. Split out of diff --git a/swarm-controller/src/matrix_account/agent_token.rs b/swarm-controller/src/matrix_account/agent_token.rs new file mode 100644 index 00000000..4240b6bc --- /dev/null +++ b/swarm-controller/src/matrix_account/agent_token.rs @@ -0,0 +1,357 @@ +//! Each agent's own account on the swarm's homeserver: created here with the +//! **swarm's** appservice token, stored at `swarm/agents//matrix/main`, +//! and pulled from there by the agent's matrix daemon itself. +//! +//! The appservice token is minted inside the matrix container and published +//! to `swarm_secret_client::matrix::swarm_appservice_token_path`, which only +//! matrix-ctl and this daemon may read. No hive holds it, and no hive mints an +//! agent's account any more. +//! +//! ⚠️ **Every mint replaces the agent's live token.** Both calls pin the +//! device id `hyperhive-`, so a login for an existing account replaces +//! that device's token: the one in the store before, or the file token a hive +//! minted. That is why the decision reads the stored token back with `whoami` +//! ([`classify`]) instead of only checking that the store holds something, and +//! why a failed read never mints ([`Observed::Unknown`]). +//! +//! Two callers insert the same `MintAgentMatrixAccount` job node: agent +//! creation, and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`] +//! over every agent that holds a store identity — the same roster and shape as +//! `crate::forge::agent_token`. + +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use swarm_matrix_client::{self as homeserver, Whoami}; +use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, matrix, policy}; + +/// The account name every agent's own matrix credential is stored under. +/// +/// `main` is what `nix/agent-modules/matrix.nix` declares per agent and what +/// the agent's daemon reads first. ⚠️ `PUT .../matrix-accounts/{account}` +/// refuses this exact name (`super::is_reserved_account`): that route stores +/// an **external** account an operator supplies, and one called `main` would +/// overwrite this. Two writers, one reserved name, and the reservation is what +/// keeps them apart. +pub const ACCOUNT: &str = "main"; + +/// How often [`spawn`] re-checks every agent's account. +const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); + +/// Why an account's token has to be (re)minted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MintReason { + /// The store holds nothing for this agent: a new agent, or one whose + /// account a hive created before the swarm did this. + NotStored, + /// The homeserver does not know the stored token: another login on the + /// same device replaced it. + Revoked, + /// The stored token is live but belongs to another account. + OtherUser, +} + +/// What one look at an agent's stored token found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Probe { + /// Nothing at the agent's path. + NotStored, + /// The homeserver's verdict on the stored token. + Whoami(Whoami), +} + +/// What to do about one agent's account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// The stored token is a live token for this agent's own account. + Keep, + /// Mint and store a new one. + Mint(MintReason), +} + +/// What one pass found for one agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Observed { + /// Both reads worked, and this is what [`classify`] made of them. + Decided(Decision), + /// A read failed. Nothing is known, so nothing is done. + Unknown, +} + +/// The localpart of a full user id, `@:`. +fn localpart(user_id: &str) -> Option<&str> { + user_id.strip_prefix('@')?.split_once(':').map(|(l, _)| l) +} + +/// Decide what to do about `agent`'s account from what its stored token is. +pub fn classify(agent: &str, probe: &Probe) -> Decision { + match probe { + Probe::NotStored => Decision::Mint(MintReason::NotStored), + Probe::Whoami(Whoami::UnknownToken) => Decision::Mint(MintReason::Revoked), + Probe::Whoami(Whoami::User(user)) if localpart(user) == Some(agent) => Decision::Keep, + Probe::Whoami(Whoami::User(_)) => Decision::Mint(MintReason::OtherUser), + } +} + +/// The agents a pass mints for. +/// +/// [`Observed::Unknown`] never mints: a homeserver or store outage must not +/// turn into a new token for every agent, each of which kills the one the +/// agent is running on. +pub fn plan(observed: &[(String, Observed)]) -> Vec { + observed + .iter() + .filter(|(_, o)| matches!(o, Observed::Decided(Decision::Mint(_)))) + .map(|(agent, _)| agent.clone()) + .collect() +} + +/// The swarm appservice token, or an error naming why there is none. +async fn appservice_token(store: &SecretStore) -> Result { + let path = matrix::swarm_appservice_token_path()?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + match stored { + Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()), + _ => bail!( + "nothing at {path}: the matrix container has not published the swarm \ + appservice token (swarm-matrix-appservice-publish)" + ), + } +} + +/// What the store and the homeserver say about `agent`'s stored token. +async fn probe( + store: &SecretStore, + http: &reqwest::Client, + base: &str, + agent: &str, +) -> Result { + let path = matrix::account_path(agent, ACCOUNT)?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + let Some(stored) = stored else { + return Ok(Probe::NotStored); + }; + let verdict = homeserver::whoami(http, base, stored.value.trim()) + .await + .with_context(|| format!("asking the homeserver about the token at {path}"))?; + Ok(Probe::Whoami(verdict)) +} + +/// Make sure `agent` holds a live token for its own account on the swarm's +/// homeserver at `base`, creating the account or logging in to it when it +/// does not. The whole job of the `MintAgentMatrixAccount` node. +/// +/// Reads the new token back with `whoami` before reporting success, so the +/// node does not report success on a write nobody has read. A crash between +/// the login and the write leaves a dead token in the store, which the next +/// pass classifies as [`MintReason::Revoked`]. +/// +/// # Errors +/// When the store or the homeserver refuses a step, the swarm appservice +/// token has not been published, or the new token authenticates as someone +/// else. +pub async fn ensure_agent_matrix_account(base: &str, agent: &str) -> Result<()> { + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + let as_token = appservice_token(&store).await?; + let http = homeserver::client()?; + let reason = match classify(agent, &probe(&store, &http, base, agent).await?) { + Decision::Keep => { + tracing::debug!(agent, "agent matrix account is current; left as it is"); + return Ok(()); + } + Decision::Mint(reason) => reason, + }; + + let token = match homeserver::register(&http, base, agent, &as_token).await? { + homeserver::Registered::Token(token) => token, + // An agent minted before, or one a hive created back when hives did + // this: log in as the appservice on the same device instead. + homeserver::Registered::AlreadyExists => { + homeserver::appservice_login(&http, base, agent, &as_token).await? + } + }; + let path = matrix::account_path(agent, ACCOUNT)?; + store + .write( + &path, + &matrix::Credential { + value: token.clone(), + homeserver: Some(base.to_owned()), + }, + ) + .await + .with_context(|| format!("storing {agent}'s matrix token at {path}"))?; + + match homeserver::whoami(&http, base, &token) + .await + .with_context(|| format!("using {agent}'s new matrix token"))? + { + Whoami::User(user) if localpart(&user) == Some(agent) => {} + _ => bail!("{agent}'s new matrix token does not authenticate as {agent}"), + } + tracing::info!(agent, ?reason, %path, "agent matrix account token minted and stored"); + Ok(()) +} + +/// One pass: every agent holding a store identity, observed. +/// +/// Fails as a whole, rather than per agent, when the swarm appservice token is +/// not published: every mint would fail on it, and one message says so. +async fn observe_all(base: &str) -> Result> { + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + appservice_token(&store).await?; + let http = homeserver::client()?; + let roles = store + .list_cert_roles(DEFAULT_CERT_MOUNT) + .await + .context("listing the store's cert-auth roles")?; + let mut observed = Vec::new(); + for agent in policy::agents_from_role_names(&roles) { + let o = match probe(&store, &http, base, &agent).await { + Ok(p) => Observed::Decided(classify(&agent, &p)), + Err(e) => { + tracing::warn!(agent, error = %format!("{e:#}"), "agent matrix account: read failed"); + Observed::Unknown + } + }; + observed.push((agent, o)); + } + Ok(observed) +} + +/// Check every agent's account now and every [`RECONCILE_INTERVAL`] after, and +/// hand the agents that need a token to `enqueue`, which inserts a +/// `MintAgentMatrixAccount` node for each. +/// +/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the +/// agents that can read what the node writes. A pass that fails is logged and +/// retried on the next tick; it never stops the daemon. +pub fn spawn(base: Arc, enqueue: impl Fn(Vec) + Send + 'static) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + loop { + ticker.tick().await; + match observe_all(&base).await { + Ok(observed) => { + let agents = plan(&observed); + if agents.is_empty() { + tracing::debug!( + checked = observed.len(), + "agent matrix accounts: all current" + ); + } else { + tracing::info!( + checked = observed.len(), + minting = agents.len(), + "agent matrix accounts: queueing mints" + ); + enqueue(agents); + } + } + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + retry_in_s = RECONCILE_INTERVAL.as_secs(), + "agent matrix accounts: pass failed; retrying next tick" + ), + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(id: &str) -> Probe { + Probe::Whoami(Whoami::User(id.to_owned())) + } + + #[test] + fn a_live_token_for_the_agent_is_kept() { + assert_eq!(classify("atlas", &user("@atlas:t.local")), Decision::Keep); + } + + #[test] + fn nothing_stored_mints() { + assert_eq!( + classify("atlas", &Probe::NotStored), + Decision::Mint(MintReason::NotStored) + ); + } + + #[test] + fn a_replaced_token_mints() { + // The shape a hive re-login on the same device leaves behind. + assert_eq!( + classify("atlas", &Probe::Whoami(Whoami::UnknownToken)), + Decision::Mint(MintReason::Revoked) + ); + } + + #[test] + fn a_token_for_another_account_mints() { + // Localpart compared whole, so a prefix of the agent's name is not it. + for other in ["@argus:t.local", "@atlas2:t.local", "@atla:t.local"] { + assert_eq!( + classify("atlas", &user(other)), + Decision::Mint(MintReason::OtherUser), + "{other}" + ); + } + } + + #[test] + fn a_pass_mints_only_what_it_knows_needs_one() { + let observed = [ + ("a".to_owned(), Observed::Decided(Decision::Keep)), + ( + "b".to_owned(), + Observed::Decided(Decision::Mint(MintReason::NotStored)), + ), + ("c".to_owned(), Observed::Unknown), + ( + "d".to_owned(), + Observed::Decided(Decision::Mint(MintReason::Revoked)), + ), + ]; + assert_eq!(plan(&observed), ["b", "d"]); + } + + #[test] + fn an_outage_plans_nothing() { + // Each mint replaces the token an agent is running on, so a pass that + // could not read must not mint for everyone. + let observed = [ + ("a".to_owned(), Observed::Unknown), + ("b".to_owned(), Observed::Unknown), + ]; + assert!(plan(&observed).is_empty()); + } + + #[test] + fn the_published_path_is_the_hiveless_one_the_agent_reads() { + // The literal is the point, and the absence of a hive segment is the + // half of it that was decided: an agent's account follows the agent. + assert_eq!( + matrix::account_path("atlas", ACCOUNT).expect("a plain name is legal"), + "swarm/agents/atlas/matrix/main" + ); + } + + #[test] + fn the_account_name_is_the_one_the_agent_module_declares() { + // `nix/agent-modules/matrix.nix` renders this as a `matrixAccounts` + // key and nothing wires an override across. + assert_eq!(ACCOUNT, "main"); + } +}