swarm-controller: mint each agent's matrix account with the swarm's token

A `MintAgentMatrixAccount` node creates the agent's account on the swarm's
homeserver with the swarm appservice token, stores its token at
`swarm/agents/<agent>/matrix/main`, and reads it back with whoami before
reporting success. It is a root of agent creation, `after_any` into the
deploy, and a five-minute backfill over every agent with a store identity
queues the same node — the shape of the forge-token mint.

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

`matrixHomeserverUrl` now defaults to the swarm's `chat.` vhost, since the
mint is what consults it.
This commit is contained in:
atlas 2026-09-25 00:25:06 +02:00 • committed by mara
commit 2776e121e5
7 changed files with 626 additions and 36 deletions

1
Cargo.lock generated
View file

@ -4913,6 +4913,7 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"strum", "strum",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-matrix-client",
"swarm-queue-client", "swarm-queue-client",
"swarm-secret-client", "swarm-secret-client",
"time", "time",

View file

@ -227,11 +227,9 @@ let
SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl; SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl;
}; };
# Swarm-wide default for `PUT .../matrix-accounts/{account}`'s own # The swarm's homeserver: where `MintAgentMatrixAccount` creates each
# `homeserver` field, for a request that omits one. Same shape as # agent's own account. Gated on the option resolving: a swarm with no
# `authBridgeEnv` above: genuinely optional, gated on the option # domain has no homeserver URL, and the mint node then fails by name.
# resolving rather than assumed. Not read by anything yet — see the
# option's own description.
matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) { matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) {
SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl; SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl;
}; };
@ -561,19 +559,24 @@ in
matrixHomeserverUrl = lib.mkOption { matrixHomeserverUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; # The same `chat.<swarm domain>` ./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"; example = "https://matrix.example.org";
description = '' 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 .../matrix-accounts/{account}` requests that omit their own
`homeserver` — see that route's own doc comment `homeserver` — see that route's own doc comment
(`swarm-controller/src/matrix_account.rs`) for why the field is (`swarm-controller/src/matrix_account.rs`) for why the field is
optional in token mode and what omitting it currently resolves to. 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.
''; '';
}; };

View file

@ -59,6 +59,15 @@ let
baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or ""); baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or "");
cases = [ 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 # 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 # added to it and every case still passed — measured, not assumed: the

View file

@ -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 # 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. # second spelling here would be a store this hive could not read.
swarm-secret-client.workspace = true 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 # `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 # 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 # here is a deploy-time `openssl` oneshot — because this one issues per agent

View file

@ -113,6 +113,14 @@ enum SwarmNodeKind {
/// Carries no hive: the token's store path has no hive segment, and the /// Carries no hive: the token's store path has no hive segment, and the
/// agent pulls it from wherever it runs. /// agent pulls it from wherever it runs.
MintAgentForgeToken { agent: String }, 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 /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
/// store, so a freshly created agent does not start driving turns the /// 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`. /// 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::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".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::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
} }
@ -157,7 +166,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::CreateForgeUser { agent }
| SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent } | SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } => { | SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
serde_json::json!({ "agent": agent }) serde_json::json!({ "agent": agent })
} }
SwarmNodeKind::TriggerDeploy { hive, agent } SwarmNodeKind::TriggerDeploy { hive, agent }
@ -209,6 +219,11 @@ struct WorkerDeps {
/// see `wanted_writer`. `None` exactly when no swarm queue is configured /// see `wanted_writer`. `None` exactly when no swarm queue is configured
/// on this host, same as `queue`. /// on this host, same as `queue`.
wanted: Option<std::sync::Arc<wanted::WantedWriter>>, wanted: Option<std::sync::Arc<wanted::WantedWriter>>,
/// 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<std::sync::Arc<str>>,
} }
/// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be. /// 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:#}")), Err(e) => Outcome::Failed(format!("{e:#}")),
}, },
}, },
SwarmNodeKind::MintAgentIdentity { hive, agent } => match deps.agent_ca { SwarmNodeKind::MintAgentIdentity { hive, agent } => {
None => Outcome::Failed( mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await
"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::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 { SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
None => Outcome::Failed( None => Outcome::Failed(
"no swarm queue is configured on this host, so no wanted-state \ "no swarm queue is configured on this host, so no wanted-state \
@ -338,6 +345,29 @@ async fn run_swarm_node(
(builder, outcome) (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 /// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under
/// `clippy::too_many_lines`. /// `clippy::too_many_lines`.
async fn mint_forge_token( 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 /// 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 /// 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 /// `Destroyed`, which this treats as reusable) is left alone, so a retried
@ -1513,6 +1565,13 @@ fn declare_agent_job(
agent: agent.to_owned(), agent: agent.to_owned(),
}) })
.after_ok(create_forge_user); .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 // Declared before the deploy trigger so the pause is visible in the
// wanted-state store before the hive brings the container up — see 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 // 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_ok(init_config)
.after_any(mint_identity) .after_any(mint_identity)
.after_any(mint_forge_token) .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); .after_ok(set_wanted);
vec![create_identity.guid()] vec![create_identity.guid()]
} }
@ -1795,6 +1859,57 @@ fn queue_forge_token_mints(
Ok(ids) 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<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
agents: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
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<Arc<str>> {
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<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
homeserver: Option<Arc<str>>,
) {
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. /// 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 /// 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()), queue: status.as_ref().map(|s| s.queue_client()),
agent_ca: load_agent_authority(), agent_ca: load_agent_authority(),
wanted: wanted_writer(status.as_ref()), wanted: wanted_writer(status.as_ref()),
matrix_homeserver: configured_matrix_homeserver(),
}; };
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new( let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),
))); )));
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone());
spawn_jobq_worker(Arc::clone(&jobq), deps); spawn_jobq_worker(Arc::clone(&jobq), deps);
// Bound to a named variable, not `_` — dropping the provider stops its // Bound to a named variable, not `_` — dropping the provider stops its
// `PeriodicReader`, so it must live as long as `main` does (which it // `PeriodicReader`, so it must live as long as `main` does (which it
@ -2844,6 +2961,7 @@ mod tests {
queue: None, queue: None,
agent_ca: None, agent_ca: None,
wanted: None, wanted: None,
matrix_homeserver: None,
}; };
let runner = let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -2898,6 +3016,7 @@ mod tests {
queue: None, queue: None,
agent_ca: None, agent_ca: None,
wanted: None, wanted: None,
matrix_homeserver: None,
}; };
let runner = let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -2952,6 +3071,7 @@ mod tests {
queue: None, queue: None,
agent_ca: None, agent_ca: None,
wanted: None, wanted: None,
matrix_homeserver: None,
}; };
let runner = let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -3004,6 +3124,7 @@ mod tests {
queue: None, queue: None,
agent_ca: None, agent_ca: None,
wanted: None, wanted: None,
matrix_homeserver: None,
}; };
let runner = let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -3242,6 +3363,92 @@ mod tests {
assert_eq!(kind.data(1)["agent"], "atlas"); 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<String> = 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 /// The manual route and the periodic pass both go through
/// `queue_forge_token_mints`, so this is the assertion that a backfilled /// `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, /// mint is the same pair of nodes agent creation inserts: the forge user,

View file

@ -1,5 +1,13 @@
//! Give one agent an external matrix account: put the credential in the //! An agent's matrix accounts, from this daemon's two sides of them.
//! swarm's secret store, then tell that agent's hive it is there. //!
//! **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 //! 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 //! 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}; use super::{AppState, error_problem, swarm_hive};
pub mod agent_token;
fn default_mode() -> String { fn default_mode() -> String {
"token".to_owned() "token".to_owned()
} }
@ -41,9 +51,9 @@ fn default_mode() -> String {
/// when a caller omits one. Read via [`configured_default_homeserver`], not /// when a caller omits one. Read via [`configured_default_homeserver`], not
/// directly — see that fn's doc. /// directly — see that fn's doc.
/// ///
/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default` /// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet
/// below exists for a later slice of this homeserver-default rollout to /// consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
/// call; this one only wires the config through. /// 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"; pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
/// `caller`'s own homeserver, or `default` when the caller left it unset. /// `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 })) Ok(Json(PutMatrixAccountResponse { user_id }))
} }
/// Whether `account` is the hive-internal name every hive declares per /// Whether `account` is the agent's own account, which [`agent_token`] mints
/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment /// and `nix/agent-modules/matrix.nix` declares per agent — see the call site's
/// for why this route must never write one. /// own comment for why this route must never write one.
fn is_reserved_account(account: &str) -> bool { 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 /// Token-mode's only requirement: a token was actually given. Split out of

View file

@ -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/<agent>/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-<agent>`, 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, `@<localpart>:<server>`.
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<String> {
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<String> {
let path = matrix::swarm_appservice_token_path()?;
let stored: Option<matrix::Credential> = 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<Probe> {
let path = matrix::account_path(agent, ACCOUNT)?;
let stored: Option<matrix::Credential> = 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<Vec<(String, Observed)>> {
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<str>, enqueue: impl Fn(Vec<String>) + 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");
}
}