hive-matrix-mcp: read the main account's token from the store too
The daemon reads each account's token from `swarm/agents/<agent>/matrix/` as the agent itself, inside its own container, and falls back to the file only when the store has none. This is #4519's read, without its `main` carve-out: the swarm now mints `main` there and no hive writes the file. The daemon unit gets the agent's store identity, spelled the way forge-token.nix spells it. A timer re-starts it while it is down: a token the swarm mints or replaces in the store changes no file, so the path watcher never fires for it, and a daemon that exited on a replaced token would otherwise stay down until the container restarts.
This commit is contained in:
parent
2776e121e5
commit
ab153bda2f
9 changed files with 670 additions and 85 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1989,6 +1989,7 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ rmcp.workspace = true
|
|||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
//! matrix-sdk `Client` setup for the daemon: read the per-agent access
|
||||
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for`
|
||||
//! wrote, probe `whoami` to recover the agent's matrix `user_id` +
|
||||
//! `device_id`, restore the matrix-sdk session, return the Client ready
|
||||
//! to start sync.
|
||||
//! matrix-sdk `Client` setup for the daemon: take the per-agent access
|
||||
//! token [`crate::credential`] resolved, probe `whoami` to recover the
|
||||
//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
|
||||
//! session, return the Client ready to start sync.
|
||||
//!
|
||||
//! 🩸 The token arrives as a [`Token`] and never as a path this module
|
||||
//! reads — where it came from is [`crate::credential`]'s question, and the
|
||||
//! only thing said about it here is its [`crate::credential::Origin`].
|
||||
//!
|
||||
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
||||
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
|
||||
//! the token + user/device as the hive's appservice and handed us the
|
||||
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
|
||||
//! ccc.de connection): for the swarm's tuwunel the swarm already minted the
|
||||
//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
|
||||
//! `MatrixSession` skips the login flow entirely.
|
||||
//!
|
||||
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
||||
|
|
@ -34,10 +36,12 @@ use matrix_sdk::{
|
|||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::credential::Token;
|
||||
|
||||
/// Sentinel returned when `build_and_restore` detects that the token is
|
||||
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
|
||||
/// removed from disk. Callers should NOT retry — the account needs
|
||||
/// re-provisioning by hive-c0re.
|
||||
/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
|
||||
/// account needs re-provisioning.
|
||||
///
|
||||
/// Distinct from the general `anyhow::Error` path so callers can use
|
||||
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
|
||||
|
|
@ -64,63 +68,53 @@ struct WhoamiResponse {
|
|||
}
|
||||
|
||||
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
|
||||
/// token at `token_file`. The Client points at `homeserver`, persists
|
||||
/// its sqlite cache under `state_dir`, and is ready for sync once
|
||||
/// returned.
|
||||
/// `token`. The Client points at `homeserver`, persists its sqlite cache
|
||||
/// under `state_dir`, and is ready for sync once returned.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
|
||||
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||
/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||
/// the bearer — this gives us back the matrix `user_id` +
|
||||
/// `device_id` (the registration response had them but hive-c0re
|
||||
/// only persisted the token; whoami is the cheapest recovery path
|
||||
/// `device_id` (the registration response had them but only the
|
||||
/// token was kept; whoami is the cheapest recovery path
|
||||
/// and avoids matrix-sdk's circular requirement of needing a
|
||||
/// session to call whoami).
|
||||
/// 3. Build the real Client with the sqlite store + `restore_session`
|
||||
/// 2. Build the real Client with the sqlite store + `restore_session`
|
||||
/// using a synthetic `MatrixSession`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the token file is missing or empty, if the
|
||||
/// `whoami` request fails, or if the matrix-sdk client fails to build
|
||||
/// or restore the session.
|
||||
/// Returns an error if the `whoami` request fails, or if the matrix-sdk
|
||||
/// client fails to build or restore the session.
|
||||
pub async fn build_and_restore(
|
||||
homeserver: &str,
|
||||
token_file: &Path,
|
||||
token: &Token,
|
||||
state_dir: &Path,
|
||||
is_primary: bool,
|
||||
) -> Result<Client> {
|
||||
let token = fs::read_to_string(token_file)
|
||||
.await
|
||||
.with_context(|| format!("read matrix token from {}", token_file.display()))?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if token.is_empty() {
|
||||
return Err(anyhow!("matrix token at {} is empty", token_file.display()));
|
||||
}
|
||||
|
||||
let (user_id, device_id) = match whoami(homeserver, &token).await {
|
||||
let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
if msg.contains("M_UNKNOWN_TOKEN") {
|
||||
// Homeserver rejected our token — stale session after a homeserver
|
||||
// state wipe or token expiry. Delete the stale token file so we
|
||||
// don't loop on it. The rest of the handling depends on whether
|
||||
// this is the primary (hive-internal) account or a secondary one,
|
||||
// because a single bad secondary token must NOT take down the
|
||||
// whole daemon (and with it every healthy account).
|
||||
// state wipe or token expiry. Discard it so we don't loop on it
|
||||
// (which for a store-held credential is a log line and nothing
|
||||
// else — see `Token::discard_stale`). The rest of the handling
|
||||
// depends on whether this is the primary (hive-internal) account
|
||||
// or a secondary one, because a single bad secondary token must
|
||||
// NOT take down the whole daemon (and with it every healthy
|
||||
// account).
|
||||
tracing::warn!(
|
||||
path = %token_file.display(),
|
||||
credential = %token.origin(),
|
||||
is_primary,
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
|
||||
);
|
||||
let _ = fs::remove_file(token_file).await;
|
||||
token.discard_stale().await;
|
||||
if is_primary {
|
||||
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
||||
// now-invalid session, then exit 0 so hive-c0re's `ensure_all`
|
||||
// re-provisions the account and the systemd.paths watcher
|
||||
// restarts us once the fresh token file appears. Exit 0 (not
|
||||
// now-invalid session, then exit 0; the swarm's backfill
|
||||
// re-mints the token and systemd restarts us once it
|
||||
// exists (store re-check timer, or the path watcher). Exit 0 (not
|
||||
// Err) keeps systemd's Restart=on-failure from looping; the
|
||||
// call site is before any tasks are spawned so there's
|
||||
// nothing to clean up.
|
||||
|
|
@ -133,13 +127,13 @@ pub async fn build_and_restore(
|
|||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Secondary: token removed (so it's cleanly skipped next boot
|
||||
// Secondary: token discarded (so it's cleanly skipped next boot
|
||||
// rather than re-erroring); leave the sdk state in place in case
|
||||
// the operator re-provisions a fresh token for the same device.
|
||||
// Return a PermanentBringUpError so the caller can distinguish
|
||||
// "don't retry" from a transient network/DNS failure.
|
||||
return Err(anyhow::Error::new(PermanentBringUpError(
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(),
|
||||
)));
|
||||
}
|
||||
return Err(e);
|
||||
|
|
@ -159,7 +153,7 @@ pub async fn build_and_restore(
|
|||
let session = MatrixSession {
|
||||
meta: SessionMeta { user_id, device_id },
|
||||
tokens: SessionTokens {
|
||||
access_token: token,
|
||||
access_token: token.expose().to_owned(),
|
||||
refresh_token: None,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
429
hive-matrix-mcp/src/credential.rs
Normal file
429
hive-matrix-mcp/src/credential.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
//! The matrix access token as a value this process holds, and the two places
|
||||
//! it comes from.
|
||||
//!
|
||||
//! 🩸 **A secret is a path, not a value.** Nothing here writes the token
|
||||
//! anywhere, interpolates it into a command, or lets it reach a log line or an
|
||||
//! error message: [`Token`] has no `Debug` derive, and every message below
|
||||
//! names an [`Origin`] — which is a path in the store or a path on disk, never
|
||||
//! the bytes at either.
|
||||
//!
|
||||
//! The store read is this agent reading **its own** credential under **its own
|
||||
//! identity, from inside its own container** — the path
|
||||
//! `swarm/agents/<agent>/matrix/<account>` is already agent-scoped, and the
|
||||
//! certificate the login presents is the one this agent's hive delivered as a
|
||||
//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the
|
||||
//! path of the value at all.
|
||||
//!
|
||||
//! The file arm is the hive-side delivery that still runs beside this one for
|
||||
//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a
|
||||
//! hive minted before the swarm did. That is what this replaces, not something
|
||||
//! it depends on, and it is the arm that goes when the hive-side loop does.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
||||
matrix, policy,
|
||||
};
|
||||
|
||||
/// Names the agent this container belongs to, set by
|
||||
/// `nix/agent-modules/matrix.nix` from the agent's own unix user name.
|
||||
///
|
||||
/// The name and nothing else: `swarm_secret_client` owns both spellings
|
||||
/// derived from it — the credential's path ([`matrix::account_path`]) and the
|
||||
/// cert-auth role the login selects ([`policy::agent_object_name`]) — so a
|
||||
/// path or a role name forwarded from nix would be a second copy of a string
|
||||
/// whose mismatch is a 403 that names neither.
|
||||
pub const ENV_AGENT: &str = "HIVE_AGENT_NAME";
|
||||
|
||||
/// Where a token came from.
|
||||
///
|
||||
/// A **path** in both arms, which is what makes it safe to put in a log line:
|
||||
/// the whole point of this module is that the thing beside it never is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
/// A file in this agent's state dir, written by the hive-side delivery.
|
||||
File(PathBuf),
|
||||
/// A path in the swarm secret store, read by this agent as itself.
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Origin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::File(p) => write!(f, "{}", p.display()),
|
||||
Self::Store(p) => write!(f, "the store's {p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A matrix access token, held in memory for as long as it takes to restore a
|
||||
/// session with it.
|
||||
///
|
||||
/// ⚠️ **No `Debug` derive**, for the reason
|
||||
/// [`swarm_secret_client::mtls::Credential`] states for the private key it
|
||||
/// carries: this value is threaded through `anyhow` context chains and
|
||||
/// `tracing` fields, both of which format whatever they are handed. The
|
||||
/// hand-written impl below reports the origin and the length, which is every
|
||||
/// question a reader of a log line actually has.
|
||||
pub struct Token {
|
||||
value: String,
|
||||
origin: Origin,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Token {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Token")
|
||||
.field("origin", &self.origin)
|
||||
.field(
|
||||
"value",
|
||||
&format_args!("<redacted, {} bytes>", self.value.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Token {
|
||||
/// The bytes, for the one caller that has to present them to the
|
||||
/// homeserver.
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// Where this token came from — the only half of it that may be printed.
|
||||
#[must_use]
|
||||
pub fn origin(&self) -> &Origin {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
/// Read this agent's credential for `account` out of the swarm secret
|
||||
/// store, under this agent's own certificate.
|
||||
///
|
||||
/// `Ok(None)` means this deployment has no store: the agent's hive was
|
||||
/// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no
|
||||
/// identity check and there is nothing here to log in to. That is an absent
|
||||
/// integration, not a failure — the caller falls back to the file the hive
|
||||
/// delivered.
|
||||
///
|
||||
/// # Errors
|
||||
/// A name that is not a single path segment, an environment naming an
|
||||
/// identity that cannot be read, a store that refuses the certificate or
|
||||
/// the path, or a credential stored empty.
|
||||
pub async fn from_store(agent: &str, account: &str) -> Result<Option<Self>> {
|
||||
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = matrix::account_path(agent, account)
|
||||
.context("building this agent's credential path in the store")?;
|
||||
let role = policy::agent_object_name(agent)
|
||||
.context("building this agent's cert-auth role name")?;
|
||||
|
||||
let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.context("logging in to the swarm secret store as this agent")?;
|
||||
// The stored object also carries the account's homeserver, and it is
|
||||
// deliberately dropped here: the account's URL is already settled by
|
||||
// the time this runs (`AccountCfg::homeserver`), and taking it from the
|
||||
// store instead would change which accounts come up at all — that is
|
||||
// the discovery half of this move, which goes with the hive-side loop
|
||||
// rather than with the read.
|
||||
let credential: matrix::Credential = store
|
||||
.read(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path} from the store"))?;
|
||||
|
||||
let value = credential.value.trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("the credential at {path} in the store is empty"));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
value,
|
||||
origin: Origin::Store(path),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read a token out of the file the hive-side delivery wrote.
|
||||
///
|
||||
/// `Ok(None)` when the file is not there — the account has not been
|
||||
/// provisioned yet, which the caller treats as "skip", not "fail".
|
||||
///
|
||||
/// # Errors
|
||||
/// The file existing but being unreadable or empty.
|
||||
pub async fn from_file(path: &Path) -> Result<Option<Self>> {
|
||||
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read matrix token from {}", path.display()))?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("matrix token at {} is empty", path.display()));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
value,
|
||||
origin: Origin::File(path.to_owned()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Drop this token after the homeserver rejected it, so a restart does not
|
||||
/// loop on the same dead value.
|
||||
///
|
||||
/// Only a file can be dropped. A credential in the store is not this
|
||||
/// agent's to delete — its policy grants `read` and nothing else — and
|
||||
/// replacing it is renewal, a different job from this one. Saying so is the
|
||||
/// whole of what the store arm does.
|
||||
pub async fn discard_stale(&self) {
|
||||
match &self.origin {
|
||||
Origin::File(p) => {
|
||||
let _ = tokio::fs::remove_file(p).await;
|
||||
}
|
||||
Origin::Store(path) => tracing::warn!(
|
||||
credential = %path,
|
||||
"the stored matrix credential was rejected; leaving it alone (this agent may only read it)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This agent's name, or `None` when the harness did not say.
|
||||
///
|
||||
/// `None` is not a failure: it is what an agent whose harness predates
|
||||
/// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its
|
||||
/// hive delivers.
|
||||
#[must_use]
|
||||
pub fn agent_name() -> Option<String> {
|
||||
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// The store's coordinates as this container was given them, or `None` when it
|
||||
/// was given none.
|
||||
///
|
||||
/// Split out of [`Token::from_store`] and taken through a lookup so the two
|
||||
/// decisions it makes — "is there a store at all" and "is the delivered CA
|
||||
/// usable" — can be asserted without a store to talk to or an environment to
|
||||
/// mutate.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but
|
||||
/// the identity beside it was not, which is a half-delivered container rather
|
||||
/// than one without a store.
|
||||
fn store_settings(get: impl Fn(&str) -> Option<String>) -> Result<Option<Settings>> {
|
||||
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
||||
.context("reading the swarm secret store's coordinates from the environment")?;
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
/// Whether the CA bundle at `path` is a file with bytes in it.
|
||||
///
|
||||
/// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form
|
||||
/// is non-fatal when the manager received no such credential — so the variable
|
||||
/// can name a file that is not there. Absent means "verify the store's listener
|
||||
/// against the container's own trust store", which is what a deployment with a
|
||||
/// real CA wants; handing the path through regardless would fail the TLS
|
||||
/// handshake on a file that was never meant to exist and blame the store.
|
||||
fn ca_is_usable(path: &str) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lookup standing in for a container that was handed a store.
|
||||
fn with_store(k: &str) -> Option<String> {
|
||||
match k {
|
||||
ENV_ADDR => Some("https://bao.t.local:8200".to_owned()),
|
||||
"BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()),
|
||||
"BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the hand-written `Debug` exists for: a token that reaches a
|
||||
/// log line is a token in a journal somebody else can read.
|
||||
#[test]
|
||||
fn formatting_a_token_does_not_reveal_it() {
|
||||
let token = Token {
|
||||
value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(),
|
||||
origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()),
|
||||
};
|
||||
let rendered = format!("{token:?}");
|
||||
assert!(
|
||||
!rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"),
|
||||
"the token must not survive formatting, got {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("<redacted"), "got {rendered}");
|
||||
// The control: the origin IS meant to survive, so the assertion above
|
||||
// is not passing because the impl prints nothing at all.
|
||||
assert!(
|
||||
rendered.contains("swarm/agents/a1/matrix/ccc"),
|
||||
"got {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same property for the arm a reader actually reaches for: `Display`
|
||||
/// on the origin is what every message in this crate interpolates.
|
||||
#[test]
|
||||
fn an_origin_renders_as_a_path_in_both_arms() {
|
||||
assert_eq!(
|
||||
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
|
||||
"/agents/a1/state/matrix-token-ccc"
|
||||
);
|
||||
assert_eq!(
|
||||
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
|
||||
"the store's swarm/agents/a1/matrix/ccc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_with_no_store_address_reads_no_store() {
|
||||
// The absence arm, and what the file fallback exists for: a hive that
|
||||
// was given no store forwards nothing, and this must be "there is no
|
||||
// store" rather than an error about a missing certificate.
|
||||
assert!(
|
||||
store_settings(|_| None)
|
||||
.expect("an absent store is not a failure")
|
||||
.is_none()
|
||||
);
|
||||
// Empty is how systemd delivers an unset nix option, so it has to read
|
||||
// the same as unset.
|
||||
assert!(
|
||||
store_settings(|k| if k == ENV_ADDR {
|
||||
Some(String::new())
|
||||
} else {
|
||||
with_store(k)
|
||||
})
|
||||
.expect("an empty address is not an address")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_without_an_identity_is_a_half_delivered_container() {
|
||||
// Not the same thing as having no store: the hive said where the store
|
||||
// is and then delivered nothing to present to it, which is a deployment
|
||||
// bug and must be named rather than silently degraded.
|
||||
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
|
||||
.expect_err("an address with no certificate beside it is a failure");
|
||||
assert!(e.to_string().contains("coordinates"), "got {e:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_address_with_an_identity_is_usable() {
|
||||
// The control for the two arms above.
|
||||
assert!(
|
||||
store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
|
||||
// `LoadCredential=` in its bare form is non-fatal when the credential
|
||||
// is absent, so this variable routinely names a file that is not there.
|
||||
// The resulting settings must be the ones a container with no CA at all
|
||||
// gets — anything else fails the handshake and blames the store.
|
||||
let named = store_settings(|k| {
|
||||
if k == ENV_CACERT {
|
||||
Some("/nonexistent/server-ca.pem".to_owned())
|
||||
} else {
|
||||
with_store(k)
|
||||
}
|
||||
})
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
let absent = store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
assert_eq!(named, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ca_with_bytes_in_it_is_kept() {
|
||||
// The control: without it, the test above would pass against a
|
||||
// correction that dropped every CA it was ever given.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-cred-ca-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ca = dir.join("server-ca.pem");
|
||||
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
|
||||
assert!(ca_is_usable(ca.to_str().unwrap()));
|
||||
|
||||
let kept = store_settings(|k| {
|
||||
if k == ENV_CACERT {
|
||||
Some(ca.to_string_lossy().into_owned())
|
||||
} else {
|
||||
with_store(k)
|
||||
}
|
||||
})
|
||||
.expect("a full environment")
|
||||
.expect("a store is configured");
|
||||
assert_ne!(
|
||||
kept,
|
||||
store_settings(with_store)
|
||||
.expect("a full environment")
|
||||
.expect("configured"),
|
||||
"a delivered CA must change the settings it is delivered into"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
|
||||
// The daemon skips such an account (a secondary) or waits on the path
|
||||
// watcher (the primary); neither is a failure.
|
||||
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
|
||||
assert!(Token::from_file(&missing).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-cred-empty-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("matrix-token");
|
||||
std::fs::write(&path, " \n").unwrap();
|
||||
|
||||
let e = Token::from_file(&path)
|
||||
.await
|
||||
.expect_err("whitespace is not a token");
|
||||
assert!(
|
||||
e.to_string().contains(&path.display().to_string()),
|
||||
"got {e:#}"
|
||||
);
|
||||
|
||||
// And the control: a real token comes back, trimmed, with the file as
|
||||
// its origin.
|
||||
std::fs::write(&path, "syt_abc\n").unwrap();
|
||||
let token = Token::from_file(&path)
|
||||
.await
|
||||
.expect("a readable file")
|
||||
.expect("a non-empty one");
|
||||
assert_eq!(token.expose(), "syt_abc");
|
||||
assert_eq!(token.origin(), &Origin::File(path.clone()));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod accounts;
|
||||
pub mod client;
|
||||
pub mod credential;
|
||||
pub mod handlers;
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
|
|
|
|||
|
|
@ -7,22 +7,24 @@
|
|||
//! Lifecycle:
|
||||
//! 1. Read the configured account list (`accounts::configured()` —
|
||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
||||
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
|
||||
//! 2. For each account: resolve its access token (`account_token` — the
|
||||
//! swarm secret store first, read by this agent as itself, then the
|
||||
//! file its hive delivered) → whoami probe → recover `user_id` + `device_id`
|
||||
//! → restore matrix-sdk session (no login flow), install the
|
||||
//! message-event handler, and spawn its own sync loop.
|
||||
//! 3. Serve the MCP tools against an account→Client registry; each tool
|
||||
//! call routes to the account named in its `account` arg (the
|
||||
//! primary account when omitted).
|
||||
//!
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token file →
|
||||
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
|
||||
//! provisions it. A SECONDARY account missing its token is skipped (the
|
||||
//! daemon still serves the others).
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token →
|
||||
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
|
||||
//! a token file, the store re-check timer for a stored token). A SECONDARY
|
||||
//! account missing its token is skipped (the daemon still serves the others).
|
||||
//!
|
||||
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
|
||||
//! `client::build_and_restore`, and it mirrors the missing-token policy
|
||||
//! above — a rejected PRIMARY token drops the stale token + sdk state and
|
||||
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
|
||||
//! exits 0 until a fresh token exists, while a rejected SECONDARY token
|
||||
//! is removed and that one account is skipped so the daemon keeps serving
|
||||
//! the primary and any other healthy account.
|
||||
|
||||
|
|
@ -34,7 +36,8 @@ use matrix_sdk::{Client, config::SyncSettings};
|
|||
|
||||
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
|
||||
use hive_matrix_mcp::client::PermanentBringUpError;
|
||||
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
|
||||
use hive_matrix_mcp::credential::Token;
|
||||
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
|
||||
|
|
@ -97,13 +100,13 @@ async fn main() -> Result<()> {
|
|||
sync_loops.push(sync_loop);
|
||||
}
|
||||
// No token yet: for the primary that means the daemon isn't
|
||||
// useful — exit 0 like the legacy single-account path so the
|
||||
// systemd path-watcher restarts us when the token appears.
|
||||
// useful — exit 0, and systemd restarts us once a token exists
|
||||
// (the path-watcher for a file, the re-check timer for the store).
|
||||
Ok(None) if is_primary => {
|
||||
tracing::warn!(
|
||||
account = %cfg.name,
|
||||
"primary matrix account has no token yet; exiting cleanly \
|
||||
(systemd restarts us when hive-c0re provisions it)"
|
||||
(systemd restarts us once one exists)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry(
|
|||
None
|
||||
}
|
||||
|
||||
/// Resolve `cfg`'s access token, preferring the copy this agent can fetch
|
||||
/// itself.
|
||||
///
|
||||
/// 🏛️ The store read is the point: the credential at
|
||||
/// `swarm/agents/<agent>/matrix/<account>` is **this agent's**, and it is read
|
||||
/// from inside this agent's container under this agent's own certificate,
|
||||
/// never by the hive on the agent's behalf. It is held in memory from here to
|
||||
/// `restore_session` and is written nowhere.
|
||||
///
|
||||
/// `main` included: `swarm-controller` mints it with the swarm's appservice
|
||||
/// token and stores it at `swarm/agents/<agent>/matrix/main`, and no hive
|
||||
/// mints it any more. An agent whose harness forwards no
|
||||
/// [`credential::ENV_AGENT`] cannot name its own subtree, so it reads the file.
|
||||
///
|
||||
/// The file is what remains when the store answers nothing: an extra account
|
||||
/// the hive-side delivery loop still writes, or a `main` token a hive minted
|
||||
/// before the swarm did. A store read that *fails* falls back too, and says
|
||||
/// why.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`credential::Token::from_file`]: a token file that exists but cannot be
|
||||
/// read, or is empty.
|
||||
async fn account_token(cfg: &AccountCfg) -> Result<Option<Token>> {
|
||||
if let Some(agent) = credential::agent_name() {
|
||||
match Token::from_store(&agent, &cfg.name).await {
|
||||
Ok(Some(token)) => return Ok(Some(token)),
|
||||
// No store in this deployment: nothing to report, the hive-side
|
||||
// delivery is the whole mechanism here.
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
account = %cfg.name,
|
||||
error = %format!("{e:#}"),
|
||||
"could not read this account's credential from the store as this agent; \
|
||||
falling back to the token the hive delivered"
|
||||
),
|
||||
}
|
||||
}
|
||||
Token::from_file(&cfg.token_file).await
|
||||
}
|
||||
|
||||
/// Restore one account's client (when its token exists), install its
|
||||
/// message handler, and build its sync loop. Returns
|
||||
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
|
||||
|
|
@ -280,21 +323,17 @@ async fn bring_up_account(
|
|||
);
|
||||
return Ok(None);
|
||||
};
|
||||
if !tokio::fs::try_exists(&cfg.token_file)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let Some(token) = account_token(cfg).await? else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
account = %cfg.name,
|
||||
homeserver,
|
||||
token_file = %cfg.token_file.display(),
|
||||
credential = %token.origin(),
|
||||
state_dir = %cfg.state_dir.display(),
|
||||
"bringing up matrix account"
|
||||
);
|
||||
let client =
|
||||
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
|
||||
let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
|
||||
.await
|
||||
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
|
||||
// Best-effort: sync the agent icon to this account's matrix avatar over
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use std::path::PathBuf;
|
|||
|
||||
/// Resolve the matrix access-token file path. Override via
|
||||
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
|
||||
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent
|
||||
/// account provisioning.
|
||||
/// where a hive used to write the `main` account's token (the daemon now reads
|
||||
/// it from the store first; see `main.rs`'s `account_token`).
|
||||
#[must_use]
|
||||
pub fn token_file() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,21 @@
|
|||
}:
|
||||
let
|
||||
userName = config.services.hyperhive.agent.user.name;
|
||||
# This agent's own state dir, where hive-c0re provisions the
|
||||
# hive-internal account's token (`matrix-token`) and the daemon keeps
|
||||
# its matrix-sdk store. Shared by the `main` account entry below and
|
||||
# the path-watcher glob at the bottom of this file.
|
||||
# This agent's own state dir, where a hive used to write the `main`
|
||||
# account's token (`matrix-token`, now the store's fallback) and the
|
||||
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
|
||||
# below and the path-watcher glob at the bottom of this file.
|
||||
stateDir = "/agents/${userName}/state";
|
||||
accounts = config.services.hyperhive.agent.matrixAccounts;
|
||||
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
|
||||
# reads each account's token from the store itself, `main` included (the
|
||||
# swarm mints that one), so the store's coordinates belong on its unit. The
|
||||
# same three credential ids ./bao.nix and ./forge-token.nix load.
|
||||
baoCfg = config.services.hyperhive.agent.bao;
|
||||
storeConfigured = baoCfg.addr != null;
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
# **The enable signal.** Matrix is on for this agent exactly when it has at
|
||||
# least one account, because an account is the only thing the daemon has to
|
||||
# do: no account, no Client, no sync, no tool surface worth injecting.
|
||||
|
|
@ -90,8 +99,9 @@ in
|
|||
Path to this account's bearer-token file. The daemon reads
|
||||
the token from here to restore the matrix session; how the
|
||||
file gets populated is the provisioner's concern
|
||||
(hive-c0re for the hive-internal `main` account, an
|
||||
operator-supplied secret for an external one). The daemon
|
||||
(for `main`, a file only when the store has no token: the
|
||||
swarm mints `main` into the store; an operator-supplied
|
||||
secret for an external one). The daemon
|
||||
skips an extra account whose token file is absent, and
|
||||
exits cleanly to wait on the path watcher when `main`'s is.
|
||||
'';
|
||||
|
|
@ -143,7 +153,7 @@ in
|
|||
homeserver (`homeserver`, else
|
||||
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
|
||||
account whose URL or token file is missing, and a `systemd.paths`
|
||||
watcher restarts it the moment hive-c0re provisions the token
|
||||
watcher restarts it the moment a token file appears
|
||||
(same path-trigger shape as `forge-avatar-sync`).
|
||||
- exposes the matrix tool surface (send_message, send_dm,
|
||||
send_reaction, send_reply, mark_read, list_rooms,
|
||||
|
|
@ -173,8 +183,8 @@ in
|
|||
like any other, so it shows up in the account list --- what you
|
||||
add here are the *further* accounts (e.g. an external
|
||||
public-matrix account). Its `tokenFile` stays pinned to
|
||||
`<state>/matrix-token` (an assertion; that is the one path
|
||||
hive-c0re provisions the hive-internal token to), and the
|
||||
`<state>/matrix-token` (an assertion; the file the daemon falls back
|
||||
to when the store has no `main` token), and the
|
||||
dashboard's link-account route refuses to create an account named
|
||||
`main` --- the entry belongs to the module, not to a provisioner.
|
||||
|
||||
|
|
@ -215,8 +225,8 @@ in
|
|||
# `main` is not a forbidden key --- this module declares it
|
||||
# itself (see the `matrixAccounts.main` definition below), so the
|
||||
# name must be allowed. What stays rejected is retargeting *its
|
||||
# token file*: hive-c0re writes the hive-internal account's token
|
||||
# to `<state>/matrix-token` and nowhere else, so an override there
|
||||
# token file*: the daemon's file fallback for `main` is
|
||||
# `<state>/matrix-token` and nothing else, so an override there
|
||||
# is an account that evaluates fine and then never restores. The
|
||||
# other two fields are free to override (a `mkDefault` each).
|
||||
#
|
||||
|
|
@ -227,8 +237,8 @@ in
|
|||
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
|
||||
message =
|
||||
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
|
||||
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
|
||||
+ "hive-internal account's token. Declare a separate account instead of "
|
||||
+ "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
|
||||
+ "main account's token. Declare a separate account instead of "
|
||||
+ "pointing `main` elsewhere.";
|
||||
}
|
||||
# Token files must land at the `matrix-token*` name the daemon
|
||||
|
|
@ -260,7 +270,7 @@ in
|
|||
# so the one it always has belongs in it. `mkDefault` per field so an
|
||||
# operator can retarget e.g. the homeserver without a
|
||||
# conflicting-definition error (the token file is pinned by an
|
||||
# assertion above, since hive-c0re owns that path).
|
||||
# assertion above, since the daemon's fallback reads that path).
|
||||
#
|
||||
# ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled`
|
||||
# from being trivially true for every agent in the hive. A `main` with no
|
||||
|
|
@ -309,6 +319,20 @@ in
|
|||
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
||||
RUST_LOG = "info";
|
||||
}
|
||||
# The store's coordinates and which agent this is, so the daemon reads
|
||||
# `swarm/agents/<agent>/matrix/<account>` as itself. The NAME and not a
|
||||
# path: `swarm_secret_client` builds both the path and the cert-auth role
|
||||
# from it. Paths only — `%d` is this unit's own credentials directory.
|
||||
// lib.optionalAttrs storeConfigured {
|
||||
HIVE_AGENT_NAME = userName;
|
||||
BAO_ADDR = baoCfg.addr;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
# Named even when no CA was delivered: a bare `LoadCredential=` is
|
||||
# non-fatal when absent, and the daemon treats a missing or empty file
|
||||
# as "use the container's own trust store".
|
||||
BAO_CACERT = "%d/${serverCaCredential}";
|
||||
}
|
||||
# Homeserver URL. hive-c0re writes this option per agent from the
|
||||
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
|
||||
# by default — agents run in a private
|
||||
|
|
@ -357,7 +381,8 @@ in
|
|||
# `on-failure`, not `always`: the daemon deliberately exits 0
|
||||
# (a clean, non-failure exit) when no token is provisioned yet
|
||||
# (see the module doc above) — the `systemd.paths` watcher
|
||||
# below re-fires it the moment hive-c0re provisions one,
|
||||
# below re-fires it the moment a token file appears (the timer
|
||||
# above, for a token in the store),
|
||||
# instead of `always` busy-looping every `RestartSec` until
|
||||
# then. Once a token exists this is no different from
|
||||
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
|
||||
|
|
@ -367,11 +392,33 @@ in
|
|||
RestartSec = 5;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
}
|
||||
# This agent's own certificate, imported by id so systemd materialises it
|
||||
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
|
||||
// lib.optionalAttrs storeConfigured {
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||
# provisions it after agent containers come up). Without this
|
||||
# Re-start the daemon while it is down, when its token lives in the store.
|
||||
# The path unit below only sees files, and a token the swarm mints or
|
||||
# replaces in the store changes no file: a daemon that exited on a missing
|
||||
# or replaced token would otherwise stay down until the container restarts.
|
||||
# Relative to the daemon's last exit, so it never fires while the daemon
|
||||
# runs; five minutes is the swarm's own re-mint cadence.
|
||||
systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) {
|
||||
description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig.OnUnitInactiveSec = "5min";
|
||||
};
|
||||
|
||||
# Re-fire the daemon when the matrix token appears (a token
|
||||
# file: an extra account the hive delivers, or a `main` from before the
|
||||
# swarm minted it). Without this
|
||||
# the daemon would exit 0 silently on first boot and the MCP
|
||||
# would have no backend until next restart. See
|
||||
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ let
|
|||
;
|
||||
})
|
||||
agent
|
||||
agentWith
|
||||
runGroup
|
||||
;
|
||||
|
||||
|
|
@ -45,6 +46,15 @@ let
|
|||
homeserver = "https://matrix.example.invalid";
|
||||
};
|
||||
};
|
||||
# The daemon that reads its tokens from the store, `main` included. Paired
|
||||
# with `agentMatrix` above — same daemon, no store — so each case below can
|
||||
# tell "carries the store's coordinates" from "every matrix daemon does".
|
||||
agentMatrixBao = agentWith {
|
||||
services.hyperhive.agent.bao.addr = "https://bao.t.local:8200";
|
||||
services.hyperhive.agent.matrix.url = "https://chat.t.local";
|
||||
};
|
||||
|
||||
daemon = machine: machine.systemd.services.hive-matrix-daemon;
|
||||
cases = [
|
||||
{
|
||||
# A homeserver URL is the whole input: from it the module derives the
|
||||
|
|
@ -102,6 +112,69 @@ let
|
|||
# No hive homeserver, so nothing may claim one.
|
||||
&& !(env ? HIVE_MATRIX_URL);
|
||||
}
|
||||
{
|
||||
# The daemon reads this agent's tokens from the store ITSELF, as itself,
|
||||
# from inside this container — `main` too, since the swarm mints it and
|
||||
# no hive does. So it needs the same identity ./agent-forge-bao.nix's
|
||||
# fetch carries, in its own credentials directory.
|
||||
name = "the matrix daemon carries this agent's own store identity";
|
||||
ok =
|
||||
let
|
||||
u = daemon agentMatrixBao;
|
||||
in
|
||||
u.serviceConfig.LoadCredential == [
|
||||
"hive-agent-bao-cert"
|
||||
"hive-agent-bao-key"
|
||||
"hive-agent-bao-server-ca"
|
||||
]
|
||||
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# The agent's name, and nothing derived from it: the daemon builds its
|
||||
# store path and its cert-auth role from this one string.
|
||||
name = "the matrix daemon is told which agent it is and not where its credentials live";
|
||||
ok =
|
||||
let
|
||||
e = (daemon agentMatrixBao).environment;
|
||||
in
|
||||
e.HIVE_AGENT_NAME == agentMatrixBao.services.hyperhive.agent.user.name
|
||||
&& !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e));
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path: every `BAO_*` entry is the store's address or a
|
||||
# file under this unit's own credentials directory, never bytes in an
|
||||
# environment `/proc/<pid>/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
|
||||
|
|
|
|||
Loading…
Reference in a new issue