matrix: the agent's daemon pulls its linked accounts from bao itself
hive-matrix-daemon now learns which external matrix accounts it has from the swarm secret store, under the agent's own certificate, and the hive push chain for matrix is gone. The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy grants on its own metadata subtree), reads each account's homeserver from its credential, and brings the accounts up with their tokens from the store. Every two minutes it lists again and exits with 75 when the set of linked accounts changed; the unit restarts on 75 without counting a failure. A listed name whose credential reads as absent is skipped and logged once. At start it removes the matrix-token-<a> / matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair as delivered; a declared tokenFile keeps its token). Removed: CredentialNotice and the $SWARM.credential.* subject and NATS grant, the controller's publish and its queue precondition on the PUT route, hive-c0re's credential subscription arm and workers/credential.rs, priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken and its helpers, and the daemon's state-dir account discovery. Kept: WriteAgentGithubToken and the external-forge path (WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a declared matrixAccounts tokenFile is still read when the store has no token for that account. Refs #4348
This commit is contained in:
parent
e04616eb70
commit
97fb76ce99
22 changed files with 553 additions and 813 deletions
|
|
@ -2,11 +2,13 @@
|
|||
//!
|
||||
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
|
||||
//! matrix-sdk `Client` each, with its own session/store dir + sync
|
||||
//! loop). Accounts come from the `HIVE_MATRIX_ACCOUNTS` env var (JSON,
|
||||
//! written by the nix harness module from
|
||||
//! loop). Accounts come from two places: the `HIVE_MATRIX_ACCOUNTS` env var
|
||||
//! (JSON, written by the nix harness module from
|
||||
//! `services.hyperhive.agent.matrixAccounts`), which declares the
|
||||
//! **hive-internal account** (named `main`) as an ordinary entry
|
||||
//! alongside any others.
|
||||
//! alongside any others; and the accounts the swarm secret store links to
|
||||
//! this agent ([`discover_linked`]), which an operator linked through the
|
||||
//! swarm UI.
|
||||
//!
|
||||
//! `main` is always present and is always the primary: it is moved to
|
||||
//! index 0 whichever position it was declared at, and if the env var
|
||||
|
|
@ -22,7 +24,7 @@
|
|||
//! `Registry::resolve` / `pick_name`) — the agent can't tell more than one
|
||||
//! account exists, so it must choose explicitly.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -30,9 +32,9 @@ use anyhow::Context as _;
|
|||
use matrix_sdk::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::paths;
|
||||
use crate::{credential, paths};
|
||||
|
||||
/// One declared matrix account. `homeserver` is optional per account
|
||||
/// One matrix account. `homeserver` is optional per account
|
||||
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
|
||||
/// same homeserver need not repeat it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -40,9 +42,10 @@ pub struct AccountCfg {
|
|||
/// Logical name the agent uses to address this account
|
||||
/// (`account` arg on the MCP tools). Unique within the daemon.
|
||||
pub name: String,
|
||||
/// Path to the bearer-token file for this account (hive-c0re
|
||||
/// writes it; the daemon reads it).
|
||||
pub token_file: PathBuf,
|
||||
/// The declared bearer-token file, read when the store has no token for
|
||||
/// this account. `None` for an account the store links, whose token only
|
||||
/// the store holds.
|
||||
pub token_file: Option<PathBuf>,
|
||||
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
|
||||
pub state_dir: PathBuf,
|
||||
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
|
||||
|
|
@ -54,9 +57,7 @@ impl AccountCfg {
|
|||
/// The effective homeserver URL — this account's own, else the
|
||||
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
|
||||
///
|
||||
/// `None` is a real answer, not a failure: the account is skipped, the
|
||||
/// same way `discover_token_accounts_in` already skips a discovered
|
||||
/// token whose homeserver sidecar is missing.
|
||||
/// `None` is a real answer, not a failure: the account is skipped.
|
||||
#[must_use]
|
||||
pub fn homeserver(&self) -> Option<String> {
|
||||
self.homeserver.clone().or_else(paths::homeserver_url)
|
||||
|
|
@ -67,10 +68,9 @@ impl AccountCfg {
|
|||
/// resolves to when it omits `account`.
|
||||
const HIVE_ACCOUNT: &str = "main";
|
||||
|
||||
/// Build the account list: everything declared in `HIVE_MATRIX_ACCOUNTS`,
|
||||
/// Build the declared account list: everything in `HIVE_MATRIX_ACCOUNTS`,
|
||||
/// with the hive-internal `main` account hoisted to index 0 (= primary)
|
||||
/// or synthesized there when the declaration doesn't carry one, plus any
|
||||
/// dashboard-provisioned accounts discovered on disk.
|
||||
/// or synthesized there when the declaration doesn't carry one.
|
||||
///
|
||||
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
|
||||
/// single-account agent is unchanged.
|
||||
|
|
@ -85,19 +85,7 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
|
|||
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut accounts = ensure_hive_account(declared)?;
|
||||
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
|
||||
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
|
||||
// already declared in config, so an account logged in via the dashboard
|
||||
// form works without a `matrixAccounts` edit + rebuild. Explicit config
|
||||
// wins on name collision — pass the configured set so discovery skips
|
||||
// those names silently (a statically-configured account keeps its token
|
||||
// on disk but is brought up from config, not discovery, so it must not
|
||||
// log a spurious "no homeserver sidecar" warning).
|
||||
let configured: std::collections::HashSet<String> =
|
||||
accounts.iter().map(|a| a.name.clone()).collect();
|
||||
accounts.extend(discover_token_accounts(&configured));
|
||||
Ok(accounts)
|
||||
ensure_hive_account(declared)
|
||||
}
|
||||
|
||||
/// Put the hive-internal `main` account at index 0 of `declared`, then
|
||||
|
|
@ -135,13 +123,13 @@ fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<Acco
|
|||
0,
|
||||
AccountCfg {
|
||||
name: HIVE_ACCOUNT.to_owned(),
|
||||
token_file: paths::token_file(),
|
||||
token_file: Some(paths::token_file()),
|
||||
state_dir: paths::matrix_state_dir(),
|
||||
homeserver: None,
|
||||
},
|
||||
),
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for a in &declared {
|
||||
if !seen.insert(a.name.as_str()) {
|
||||
anyhow::bail!("duplicate matrix account name {:?}", a.name);
|
||||
|
|
@ -150,88 +138,119 @@ fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<Acco
|
|||
Ok(declared)
|
||||
}
|
||||
|
||||
/// Scan the agent state dir for extra matrix accounts provisioned via the
|
||||
/// dashboard login form: each is a `matrix-token-<name>` file plus a
|
||||
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
|
||||
/// [`AccountCfg`] per discovered account that has BOTH files — a token
|
||||
/// without a sidecar is skipped, because the homeserver is then unknown and
|
||||
/// defaulting to the hive homeserver would be wrong for an external account.
|
||||
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
|
||||
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
|
||||
/// authoritative.
|
||||
///
|
||||
/// `configured` is the set of names already declared in
|
||||
/// `HIVE_MATRIX_ACCOUNTS`; those are brought up from config regardless of
|
||||
/// their on-disk sidecar, so discovery skips them silently rather than
|
||||
/// warning about a missing sidecar for an account that isn't actually down.
|
||||
fn discover_token_accounts(configured: &std::collections::HashSet<String>) -> Vec<AccountCfg> {
|
||||
let token_path = paths::token_file();
|
||||
let Some(state_dir) = token_path.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
discover_token_accounts_in(state_dir, configured)
|
||||
/// The accounts the store links to this agent beyond the declared ones, and
|
||||
/// the linked names that could not be brought up.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Discovery {
|
||||
/// Accounts to bring up, in the order the store lists them.
|
||||
pub accounts: Vec<AccountCfg>,
|
||||
/// Listed names the store holds no credential for.
|
||||
pub missing: Vec<String>,
|
||||
/// Linked accounts stored without a homeserver. Defaulting to the hive's
|
||||
/// would be wrong for an external account, so they are skipped.
|
||||
pub no_homeserver: Vec<String>,
|
||||
}
|
||||
|
||||
/// Body of [`discover_token_accounts`] with the state dir injected, so the
|
||||
/// scan (and the configured-name skip) is unit-testable against a tempdir.
|
||||
fn discover_token_accounts_in(
|
||||
state_dir: &Path,
|
||||
configured: &std::collections::HashSet<String>,
|
||||
) -> Vec<AccountCfg> {
|
||||
let Ok(rd) = std::fs::read_dir(state_dir) else {
|
||||
return Vec::new();
|
||||
/// Ask the store which accounts it links to this agent, as [`AccountCfg`]s
|
||||
/// for every one `declared` does not already name.
|
||||
///
|
||||
/// Empty when the harness names no agent or the container was given no store.
|
||||
///
|
||||
/// # Errors
|
||||
/// The store refusing or failing a read; see [`credential::linked_accounts`].
|
||||
pub async fn discover_linked(declared: &[AccountCfg]) -> anyhow::Result<Discovery> {
|
||||
let Some(agent) = credential::agent_name() else {
|
||||
return Ok(Discovery::default());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let Some(fname) = fname.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// `matrix-token` (no suffix) is the hive account, handled separately;
|
||||
// only `matrix-token-<name>` files are extra accounts.
|
||||
let Some(name) = fname.strip_prefix("matrix-token-") else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
let Some(linked) = credential::linked_accounts(&agent).await? else {
|
||||
return Ok(Discovery::default());
|
||||
};
|
||||
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
|
||||
let mut out = linked_cfgs(&state_root(), &declared, linked.found);
|
||||
out.missing = linked.missing;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The pure half of [`discover_linked`], with the state dir injected so it is
|
||||
/// testable without a store.
|
||||
fn linked_cfgs(
|
||||
state_root: &Path,
|
||||
declared: &HashSet<&str>,
|
||||
linked: Vec<credential::Linked>,
|
||||
) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
for l in linked {
|
||||
// A declared account is brought up from its declaration.
|
||||
if declared.contains(l.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// Already declared in config: it's brought up from `HIVE_MATRIX_ACCOUNTS`,
|
||||
// not discovery, and its on-disk token needs no sidecar. Skip silently so
|
||||
// a statically-configured account doesn't log a spurious missing-sidecar
|
||||
// warning.
|
||||
if configured.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
|
||||
let Some(homeserver) = read_account_homeserver(&sidecar) else {
|
||||
tracing::warn!(
|
||||
account = name,
|
||||
sidecar = %sidecar.display(),
|
||||
"matrix: discovered token but no homeserver sidecar; skipping account \
|
||||
(re-link the account from the swarm UI to write it)"
|
||||
);
|
||||
let Some(homeserver) = l.homeserver.filter(|h| !h.is_empty()) else {
|
||||
out.no_homeserver.push(l.name);
|
||||
continue;
|
||||
};
|
||||
out.push(AccountCfg {
|
||||
name: name.to_owned(),
|
||||
token_file: entry.path(),
|
||||
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
|
||||
out.accounts.push(AccountCfg {
|
||||
state_dir: state_root.join(format!("matrix-sdk-state-{}", l.name)),
|
||||
name: l.name,
|
||||
token_file: None,
|
||||
homeserver: Some(homeserver),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
|
||||
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
|
||||
/// absent / empty.
|
||||
fn read_account_homeserver(path: &Path) -> Option<String> {
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
json.get("homeserver")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
/// This agent's state dir, the parent of the `main` token file.
|
||||
fn state_root() -> PathBuf {
|
||||
paths::token_file()
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remove token files a hive delivered into this agent's state dir: each
|
||||
/// `matrix-account-<name>.json` homeserver sidecar, and the
|
||||
/// `matrix-token-<name>` beside it unless `declared` names `<name>`.
|
||||
///
|
||||
/// Nothing in this tree writes those files: an account linked through the
|
||||
/// swarm comes from the store. A sidecar is what marks a pair as delivered
|
||||
/// rather than an operator's: a declared `tokenFile` has none. Best-effort —
|
||||
/// a failure is logged and skipped.
|
||||
pub fn remove_delivered_files(declared: &[AccountCfg]) {
|
||||
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
|
||||
remove_delivered_files_in(&state_root(), &declared);
|
||||
}
|
||||
|
||||
/// Body of [`remove_delivered_files`] with the state dir injected.
|
||||
fn remove_delivered_files_in(state_root: &Path, declared: &HashSet<&str>) {
|
||||
let Ok(rd) = std::fs::read_dir(state_root) else {
|
||||
return;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let Some(name) = fname
|
||||
.to_str()
|
||||
.and_then(|f| f.strip_prefix("matrix-account-"))
|
||||
.and_then(|f| f.strip_suffix(".json"))
|
||||
.filter(|n| !n.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut doomed = vec![entry.path()];
|
||||
if !declared.contains(name) {
|
||||
doomed.push(state_root.join(format!("matrix-token-{name}")));
|
||||
}
|
||||
for path in doomed {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(path = %path.display(), "removed a hive-delivered matrix file");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!(
|
||||
path = %path.display(), error = %e,
|
||||
"could not remove a hive-delivered matrix file"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live status of one matrix account, as reported by [`Registry::list`]
|
||||
|
|
@ -393,10 +412,9 @@ impl Registry {
|
|||
/// file. Idempotent — skips the rewrite when the on-disk content already
|
||||
/// matches, keeping the mtime stable. Used for the boot-time publish.
|
||||
///
|
||||
/// The daemon rebuilds this file fresh on every boot (and is restarted
|
||||
/// by the `matrix-token*` path-watcher when a new account is
|
||||
/// provisioned), so the file lists exactly the accounts that restored at
|
||||
/// the last start. Real-time liveness is conveyed by the file mtime,
|
||||
/// The daemon rebuilds this file fresh on every boot, and restarts itself
|
||||
/// when the store's linked accounts change, so the file lists exactly the
|
||||
/// accounts that restored at the last start. Real-time liveness is conveyed by the file mtime,
|
||||
/// which the daemon advances on a timer via
|
||||
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
|
||||
/// down, so a reader can treat an old snapshot as stale.
|
||||
|
|
@ -454,19 +472,21 @@ fn write_accounts_snapshot_inner(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{
|
||||
AccountCfg, AccountStatus, discover_token_accounts_in, ensure_hive_account,
|
||||
heartbeat_accounts_snapshot, pick_name, write_accounts_snapshot,
|
||||
AccountCfg, AccountStatus, ensure_hive_account, heartbeat_accounts_snapshot, linked_cfgs,
|
||||
pick_name, remove_delivered_files_in, write_accounts_snapshot,
|
||||
};
|
||||
use crate::credential::Linked;
|
||||
|
||||
/// A declared account, as the nix module would serialize it.
|
||||
fn cfg(name: &str) -> AccountCfg {
|
||||
AccountCfg {
|
||||
name: name.to_owned(),
|
||||
token_file: PathBuf::from(format!("/agents/a/state/matrix-token-{name}")),
|
||||
token_file: Some(PathBuf::from(format!(
|
||||
"/agents/a/state/matrix-token-{name}"
|
||||
))),
|
||||
state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")),
|
||||
homeserver: Some(format!("https://{name}.example")),
|
||||
}
|
||||
|
|
@ -482,7 +502,7 @@ mod tests {
|
|||
assert_eq!(out[0].name, "main");
|
||||
assert_eq!(
|
||||
out[0].token_file,
|
||||
PathBuf::from("/agents/a/state/matrix-token-main")
|
||||
Some(PathBuf::from("/agents/a/state/matrix-token-main"))
|
||||
);
|
||||
assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example"));
|
||||
}
|
||||
|
|
@ -521,9 +541,43 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_skips_configured_names_and_orphan_tokens() {
|
||||
fn linked_accounts_skip_declared_names_and_ones_with_no_homeserver() {
|
||||
let linked = vec![
|
||||
Linked {
|
||||
name: "catgirl".to_owned(),
|
||||
homeserver: Some("https://declared.example".to_owned()),
|
||||
},
|
||||
Linked {
|
||||
name: "bare".to_owned(),
|
||||
homeserver: None,
|
||||
},
|
||||
Linked {
|
||||
name: "good".to_owned(),
|
||||
homeserver: Some("https://good.example".to_owned()),
|
||||
},
|
||||
];
|
||||
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
|
||||
let out = linked_cfgs(Path::new("/agents/a/state"), &declared, linked);
|
||||
|
||||
let names: Vec<&str> = out.accounts.iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(names, ["good"]);
|
||||
assert_eq!(out.no_homeserver, ["bare"]);
|
||||
let good = &out.accounts[0];
|
||||
// The store holds the token, so there is no file to fall back to; the
|
||||
// session dir keeps the name a hive-delivered account had, so its
|
||||
// crypto store carries over.
|
||||
assert_eq!(good.token_file, None);
|
||||
assert_eq!(
|
||||
good.state_dir,
|
||||
PathBuf::from("/agents/a/state/matrix-sdk-state-good")
|
||||
);
|
||||
assert_eq!(good.homeserver.as_deref(), Some("https://good.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_files_go_and_operator_files_stay() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-acct-disc-{}-{}",
|
||||
"hh-acct-clean-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -531,33 +585,35 @@ mod tests {
|
|||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// A statically-configured account: token on disk, NO sidecar.
|
||||
let sidecar = r#"{"homeserver":"https://x.example"}"#;
|
||||
// Delivered and not declared: both files go.
|
||||
std::fs::write(dir.join("matrix-token-linked"), "tok").unwrap();
|
||||
std::fs::write(dir.join("matrix-account-linked.json"), sidecar).unwrap();
|
||||
// Delivered and also declared: the sidecar goes, the declared token stays.
|
||||
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
|
||||
// An orphan dashboard token: no sidecar, not configured.
|
||||
std::fs::write(dir.join("matrix-token-orphan"), "tok").unwrap();
|
||||
// A fully dashboard-provisioned account: token + sidecar.
|
||||
std::fs::write(dir.join("matrix-token-good"), "tok").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("matrix-account-good.json"),
|
||||
r#"{"homeserver":"https://good.example"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The hive account's own token must never be treated as an extra.
|
||||
std::fs::write(dir.join("matrix-account-catgirl.json"), sidecar).unwrap();
|
||||
// An operator's by-hand token has no sidecar, and the hive account's own
|
||||
// token is not an extra: both stay.
|
||||
std::fs::write(dir.join("matrix-token-byhand"), "tok").unwrap();
|
||||
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
|
||||
|
||||
let configured: HashSet<String> = ["main", "catgirl"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
let mut found: Vec<String> = discover_token_accounts_in(&dir, &configured)
|
||||
.into_iter()
|
||||
.map(|a| a.name)
|
||||
.collect();
|
||||
found.sort();
|
||||
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
|
||||
remove_delivered_files_in(&dir, &declared);
|
||||
|
||||
// catgirl (configured) + orphan (no sidecar) skipped; only `good` discovered.
|
||||
assert_eq!(found, vec!["good".to_owned()]);
|
||||
let mut left: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[
|
||||
"matrix-token",
|
||||
"matrix-token-byhand",
|
||||
"matrix-token-catgirl"
|
||||
]
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The matrix access token as a value this process holds, and the two places
|
||||
//! it comes from.
|
||||
//! it comes from; and the accounts the store links to this agent.
|
||||
//!
|
||||
//! 🩸 **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
|
||||
|
|
@ -14,10 +14,12 @@
|
|||
//! 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.
|
||||
//! Which accounts exist is read the same way: [`linked_accounts`] lists this
|
||||
//! agent's `matrix/` directory, which its policy grants `list` on
|
||||
//! (`swarm_secret_client::policy::render_agent`).
|
||||
//!
|
||||
//! The file arm is a `tokenFile` an operator declared in `matrixAccounts`, or
|
||||
//! the `main` token a hive minted before the swarm did.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -44,7 +46,7 @@ pub const ENV_AGENT: &str = "HIVE_AGENT_NAME";
|
|||
/// 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.
|
||||
/// A `tokenFile` declared for this account.
|
||||
File(PathBuf),
|
||||
/// A path in the swarm secret store, read by this agent as itself.
|
||||
Store(String),
|
||||
|
|
@ -102,34 +104,21 @@ impl Token {
|
|||
/// 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.
|
||||
/// `Ok(None)` means this container was given no store (no `BAO_ADDR`).
|
||||
///
|
||||
/// # 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 Some(store) = connect(agent).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// The stored object also carries the account's homeserver, dropped
|
||||
// here: the account's URL is settled before this runs
|
||||
// (`AccountCfg::homeserver`), from config or from [`linked_accounts`].
|
||||
let credential: matrix::Credential = store
|
||||
.read(&path)
|
||||
.await
|
||||
|
|
@ -145,7 +134,7 @@ impl Token {
|
|||
}))
|
||||
}
|
||||
|
||||
/// Read a token out of the file the hive-side delivery wrote.
|
||||
/// Read a token out of an account's declared `tokenFile`.
|
||||
///
|
||||
/// `Ok(None)` when the file is not there — the account has not been
|
||||
/// provisioned yet, which the caller treats as "skip", not "fail".
|
||||
|
|
@ -190,11 +179,93 @@ impl Token {
|
|||
}
|
||||
}
|
||||
|
||||
/// An account the store links to this agent: its name, and the homeserver
|
||||
/// stored beside its token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Linked {
|
||||
/// The account name, as the store lists it.
|
||||
pub name: String,
|
||||
/// `None` for a credential stored without one.
|
||||
pub homeserver: Option<String>,
|
||||
}
|
||||
|
||||
/// What [`linked_accounts`] found.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LinkedAccounts {
|
||||
/// The listed accounts the store holds a credential for.
|
||||
pub found: Vec<Linked>,
|
||||
/// Listed names whose newest version the store answers 404 for: an account
|
||||
/// whose credential was deleted while its metadata stays listed.
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
/// The accounts the store lists under `agent`'s `matrix/` directory, each read
|
||||
/// for its homeserver.
|
||||
///
|
||||
/// `Ok(None)` means this container was given no store (no `BAO_ADDR`). An
|
||||
/// empty directory is an agent with no linked accounts, not an error.
|
||||
///
|
||||
/// # Errors
|
||||
/// A name that is not a single path segment, an environment naming an
|
||||
/// identity that cannot be read, or a store that refuses the certificate, the
|
||||
/// listing or a path. A policy minted without `list` on the agent's metadata
|
||||
/// path is refused the listing.
|
||||
pub async fn linked_accounts(agent: &str) -> Result<Option<LinkedAccounts>> {
|
||||
let dir = matrix::accounts_dir(agent).context("building this agent's accounts path")?;
|
||||
let Some(store) = connect(agent).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let keys = store
|
||||
.list(&dir)
|
||||
.await
|
||||
.with_context(|| format!("listing {dir} in the store"))?;
|
||||
let mut out = LinkedAccounts::default();
|
||||
// A key ending in `/` is a directory below `dir`, not an account.
|
||||
for name in keys.into_iter().filter(|k| !k.ends_with('/')) {
|
||||
let path = matrix::account_path(agent, &name)
|
||||
.with_context(|| format!("building the path of listed account {name:?}"))?;
|
||||
let stored: Option<matrix::Credential> = store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path} from the store"))?;
|
||||
match stored {
|
||||
Some(credential) => out.found.push(Linked {
|
||||
name,
|
||||
homeserver: credential.homeserver,
|
||||
}),
|
||||
None => out.missing.push(name),
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Log in to the store as `agent`, or `None` when this container was given no
|
||||
/// store.
|
||||
///
|
||||
/// `None` is an absent integration, not a failure: the agent's hive was given
|
||||
/// no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no identity
|
||||
/// and there is nothing to log in to.
|
||||
///
|
||||
/// # Errors
|
||||
/// An environment naming an identity that cannot be read, or a store that
|
||||
/// refuses the certificate.
|
||||
async fn connect(agent: &str) -> Result<Option<SecretStore>> {
|
||||
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
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")?;
|
||||
Ok(Some(store))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// [`ENV_AGENT`] looks like, and such an agent keeps working off its declared
|
||||
/// token files.
|
||||
#[must_use]
|
||||
pub fn agent_name() -> Option<String> {
|
||||
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@
|
|||
//! respawn every turn.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Read the configured account list (`accounts::configured()` —
|
||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
||||
//! 1. Read the account list: `accounts::configured()` (declared) plus
|
||||
//! `accounts::discover_linked` (linked in the swarm secret store).
|
||||
//! 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`
|
||||
//! swarm secret store first, read by this agent as itself, then a declared
|
||||
//! token file) → 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).
|
||||
//! 3. Serve the MCP tools against an account→Client registry; each tool call
|
||||
//! routes to its `account` arg (the primary account when omitted).
|
||||
//! 4. Every [`LINKED_REFRESH`], re-read the linked accounts; exit with
|
||||
//! [`ACCOUNTS_CHANGED_EXIT`] when the set changed, so systemd restarts us.
|
||||
//!
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token →
|
||||
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
|
||||
|
|
@ -28,6 +29,8 @@
|
|||
//! is removed and that one account is skipped so the daemon keeps serving
|
||||
//! the primary and any other healthy account.
|
||||
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -68,8 +71,18 @@ const ACCOUNTS_HEARTBEAT_SECS: u64 = 30;
|
|||
/// case; the total wait is ~52s before we give up.
|
||||
const SECONDARY_RETRY_DELAYS_SECS: &[u64] = &[2, 5, 15, 30];
|
||||
|
||||
/// How often the daemon re-reads the store's linked accounts. A newly linked
|
||||
/// account comes up within this long of the swarm UI storing it.
|
||||
const LINKED_REFRESH: std::time::Duration = std::time::Duration::from_mins(2);
|
||||
|
||||
/// The exit status the daemon leaves with when the store's linked accounts
|
||||
/// changed. `nix/agent-modules/matrix.nix` names the same number in the unit's
|
||||
/// `RestartForceExitStatus` and `SuccessExitStatus`, so systemd restarts it
|
||||
/// without counting a failure.
|
||||
const ACCOUNTS_CHANGED_EXIT: u8 = 75;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
async fn main() -> Result<ExitCode> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
|
@ -83,7 +96,13 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let cfgs = accounts::configured().context("read matrix account config")?;
|
||||
let declared = accounts::configured().context("read matrix account config")?;
|
||||
accounts::remove_delivered_files(&declared);
|
||||
let mut warned = HashSet::new();
|
||||
let linked = linked_now(&declared, &mut warned).await.unwrap_or_default();
|
||||
let booted = account_names(&declared, &linked);
|
||||
let mut cfgs = declared.clone();
|
||||
cfgs.extend(linked);
|
||||
let multi = cfgs.len() > 1;
|
||||
let primary = cfgs[0].name.clone();
|
||||
let mut registry = Registry::new(primary);
|
||||
|
|
@ -108,7 +127,7 @@ async fn main() -> Result<()> {
|
|||
"primary matrix account has no token yet; exiting cleanly \
|
||||
(systemd restarts us once one exists)"
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
|
||||
|
|
@ -130,7 +149,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
if registry.is_empty() {
|
||||
tracing::warn!("no matrix accounts restored; exiting cleanly");
|
||||
return Ok(());
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
|
||||
// Publish the live-account snapshot (BE-4) for the dashboard: the
|
||||
|
|
@ -174,22 +193,106 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
});
|
||||
|
||||
// Drive all per-account sync loops concurrently on this task (they
|
||||
// aren't `Send`, so no `tokio::spawn`). matrix-sdk reconnects
|
||||
// internally, so any loop returning is exceptional — log it and exit
|
||||
// so systemd restarts the whole daemon cleanly.
|
||||
let (result, idx, _rest) = futures_util::future::select_all(sync_loops).await;
|
||||
match result {
|
||||
Ok(()) => tracing::warn!(
|
||||
account_index = idx,
|
||||
"a matrix sync loop exited cleanly; restarting daemon"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
|
||||
Ok(drive(sync_loops, wait_for_linked_change(declared, booted, warned)).await)
|
||||
}
|
||||
|
||||
/// Drive all per-account sync loops concurrently on this task (they aren't
|
||||
/// `Send`, so no `tokio::spawn`) until one returns or `changed` resolves.
|
||||
/// matrix-sdk reconnects internally, so any loop returning is exceptional —
|
||||
/// log it and exit so systemd restarts the whole daemon cleanly.
|
||||
async fn drive(
|
||||
sync_loops: Vec<SyncLoop>,
|
||||
changed: impl std::future::Future<Output = ()>,
|
||||
) -> ExitCode {
|
||||
tokio::select! {
|
||||
(result, idx, _rest) = futures_util::future::select_all(sync_loops) => {
|
||||
match result {
|
||||
Ok(()) => tracing::warn!(
|
||||
account_index = idx,
|
||||
"a matrix sync loop exited cleanly; restarting daemon"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
() = changed => {
|
||||
tracing::info!("the store's linked matrix accounts changed; exiting to be restarted onto them");
|
||||
ExitCode::from(ACCOUNTS_CHANGED_EXIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// The accounts the store links to this agent beyond `declared`, or `None`
|
||||
/// when the store could not be read.
|
||||
///
|
||||
/// Each linked name that cannot be brought up is logged the first time it is
|
||||
/// seen and recorded in `warned`, so a refresh does not repeat it.
|
||||
async fn linked_now(
|
||||
declared: &[AccountCfg],
|
||||
warned: &mut HashSet<String>,
|
||||
) -> Option<Vec<AccountCfg>> {
|
||||
let found = match accounts::discover_linked(declared).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"could not read this agent's linked matrix accounts from the store"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for name in found.missing {
|
||||
if warned.insert(name.clone()) {
|
||||
tracing::warn!(
|
||||
account = %name,
|
||||
"the store lists this matrix account but holds no credential for it; skipping"
|
||||
);
|
||||
}
|
||||
}
|
||||
for name in found.no_homeserver {
|
||||
if warned.insert(name.clone()) {
|
||||
tracing::warn!(
|
||||
account = %name,
|
||||
"this linked matrix account was stored without a homeserver; skipping \
|
||||
(re-link it from the swarm UI with one)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(found.accounts)
|
||||
}
|
||||
|
||||
/// Every account name the daemon would bring up from `declared` + `linked`.
|
||||
fn account_names(declared: &[AccountCfg], linked: &[AccountCfg]) -> BTreeSet<String> {
|
||||
declared
|
||||
.iter()
|
||||
.chain(linked)
|
||||
.map(|a| a.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-read the store's linked accounts every [`LINKED_REFRESH`] and resolve
|
||||
/// once they name a different set than `booted`. A failed read is logged and
|
||||
/// skipped rather than counted as a change.
|
||||
async fn wait_for_linked_change(
|
||||
declared: Vec<AccountCfg>,
|
||||
booted: BTreeSet<String>,
|
||||
mut warned: HashSet<String>,
|
||||
) {
|
||||
let mut tick = tokio::time::interval(LINKED_REFRESH);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
// The first tick is immediate, and boot has just read the store.
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let Some(linked) = linked_now(&declared, &mut warned).await else {
|
||||
continue;
|
||||
};
|
||||
if account_names(&declared, &linked) != booted {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to bring up a secondary account, retrying with exponential backoff
|
||||
|
|
@ -276,10 +379,10 @@ async fn bring_up_secondary_with_retry(
|
|||
/// 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.
|
||||
/// The file is what remains when the store answers nothing: a `tokenFile` an
|
||||
/// operator declared, or a `main` token a hive minted before the swarm did. An
|
||||
/// account the store links has no file. 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
|
||||
|
|
@ -288,18 +391,21 @@ 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.
|
||||
// No store in this deployment: the declared file 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"
|
||||
falling back to its declared token file"
|
||||
),
|
||||
}
|
||||
}
|
||||
Token::from_file(&cfg.token_file).await
|
||||
match &cfg.token_file {
|
||||
Some(path) => Token::from_file(path).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore one account's client (when its token exists), install its
|
||||
|
|
|
|||
Loading…
Reference in a new issue