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
|
|
@ -578,18 +578,20 @@ agent has one; a `null` URL with no operator-declared account is the
|
|||
"this agent has no matrix" state.
|
||||
|
||||
**First-boot ordering**: a token can arrive after the container comes
|
||||
up — a file the hive delivers, or a token the swarm mints into the store.
|
||||
up — a declared token file, or a token the swarm mints into the store.
|
||||
Without the path-trigger sibling
|
||||
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
||||
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
|
||||
a secondary multi-account token like `matrix-token-ccc`), the daemon
|
||||
a declared secondary token file like `matrix-token-ccc`), the daemon
|
||||
would exit 0 quietly the first time it ran and the MCP would have no
|
||||
daemon until the next restart. The `.path` unit makes the appearance
|
||||
of the token re-fire the service so the daemon comes alive in the
|
||||
same boot cycle as
|
||||
provisioning. A token in the store changes no file, so a store-backed agent
|
||||
also gets a timer that restarts the daemon five minutes after it last
|
||||
exited. The same token watcher also drives avatar setting: on a
|
||||
exited. A running daemon re-lists its linked accounts in the store
|
||||
every two minutes and, when the set changed, exits with status
|
||||
75, which the unit's `RestartForceExitStatus` restarts. The same token watcher also drives avatar setting: on a
|
||||
restart the daemon re-runs each account's bring-up, which sets the
|
||||
avatar (see below).
|
||||
|
||||
|
|
|
|||
|
|
@ -104,8 +104,15 @@ evaluation with a message saying so. Declaring an external account with
|
|||
its own `homeserver` is enough on its own; a hive homeserver isn't
|
||||
required.
|
||||
|
||||
An account linked from the swarm UI needs no entry in this map.
|
||||
`swarm-controller` stores it at `swarm/agents/<agent>/matrix/<account>`.
|
||||
The daemon lists that directory under the agent's own certificate at
|
||||
start and every two minutes, and restarts itself when the set of linked
|
||||
accounts changes. A declared entry of the same name wins.
|
||||
|
||||
Every matrix tool above takes an optional `account` parameter (a name
|
||||
from this map) to act as that identity instead of the primary one.
|
||||
from this map, or a linked account's) to act as that identity instead
|
||||
of the primary one.
|
||||
|
||||
Declaring an extra account also changes what the agent *receives*: wake
|
||||
bodies and invite todos gain an `[acct:<name>]` prefix — see
|
||||
|
|
|
|||
|
|
@ -295,11 +295,10 @@ known operations; there is no arbitrary command pass-through:
|
|||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
|
||||
| `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
|
||||
| `ControlInfraContainer` | `systemctl <action> container@<container>.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) |
|
||||
| `SyncAgentTmpfiles` | legacy: unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok`; kept one release for an older hive-c0re |
|
||||
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
||||
| `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir (same semantics as the forge/matrix token writes) |
|
||||
| `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir (same semantics as the extra-forge account writes) |
|
||||
| `WriteAgentExtraForgeAccount` / `DeleteAgentExtraForgeAccount` | write / remove `forge-<label>-token` + a `forge-<label>.json` base-URL sidecar, both `0600`. hive-priv validates `label` as a plain identifier before it reaches the filename — an unchecked one traverses out of the state dir |
|
||||
| `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container |
|
||||
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
// (required, here) homeserver itself and stores the token
|
||||
// that comes back. The password is sent once, over this
|
||||
// PUT, straight to swarm-controller — never held here past
|
||||
// the request, never sent to the hive.
|
||||
// the request, never stored.
|
||||
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
|
||||
// 200 (`{ user_id? }`) on success, 400/503/500 on the documented failure
|
||||
// 200 (`{ user_id? }`) on success, 400/500 on the documented failure
|
||||
// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
|
||||
// same as every other form here, since the response is `problem+json`
|
||||
// regardless of which arm fired.
|
||||
|
|
@ -138,9 +138,9 @@ export function LinkMatrixAccountForm({
|
|||
onClose={onClose}
|
||||
>
|
||||
<p>
|
||||
Writes the credential to the swarm secret store and notifies{" "}
|
||||
<strong>{hive}</strong> to deliver it. The agent's own matrix daemon
|
||||
picks it up next — nothing here restarts anything directly.
|
||||
Writes the credential to the swarm secret store. The agent's own matrix
|
||||
daemon reads it from there within two minutes — nothing here restarts
|
||||
anything directly.
|
||||
</p>
|
||||
<form class="link-matrix-account-form" onSubmit={submit}>
|
||||
<TextField
|
||||
|
|
|
|||
|
|
@ -94,9 +94,7 @@ enum Skipped {
|
|||
async fn collect(agent: &str, dir: &Path) -> Result<(), Skipped> {
|
||||
// The hive's name is the cert-auth role it logs in as: `glue-bao-tls.nix`
|
||||
// mints this host's client certificate with the hive name as its CN and a
|
||||
// bao cert role matches on CN, so the two share a name by construction —
|
||||
// the same reasoning `swarm_status::handle_credential_notice` records for
|
||||
// the other read this hive does on an agent's behalf.
|
||||
// bao cert role matches on CN, so the two share a name by construction.
|
||||
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
|
||||
return Err(Skipped::NotConfigured(
|
||||
"HYPERHIVE_HIVE_NAME is unset, so this hive has no role to log in as",
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
|||
/// store identity.
|
||||
///
|
||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||
/// the same role string `workers::credential` logs in with, and already in
|
||||
/// the same role string `lifecycle::agent_identity` logs in with, and already in
|
||||
/// this process's environment, so the store read costs no plumbing through
|
||||
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
||||
/// what makes this read reach **this** hive's token and 403 on any other's:
|
||||
|
|
|
|||
|
|
@ -369,32 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Write a Matrix access token for `agent_name` via hive-priv (running as
|
||||
/// root). `account: None` writes the hive-internal
|
||||
/// `<state>/matrix-token`; `account: Some(name)` writes
|
||||
/// `<state>/matrix-token-<name>` for an extra (external) account. The file
|
||||
/// is written 0600 and chowned to the agent user so it is readable from
|
||||
/// inside the agent container. hive-priv validates the account suffix.
|
||||
///
|
||||
/// `homeserver: Some(url)` (only meaningful with `account: Some`) also
|
||||
/// writes the sidecar `<state>/matrix-account-<name>.json` so the daemon
|
||||
/// can auto-discover the extra account without a `matrixAccounts` config
|
||||
/// declaration (see issue tracker "external matrix account auto-discovery").
|
||||
pub async fn write_agent_matrix_token(
|
||||
agent_name: &str,
|
||||
token: &str,
|
||||
account: Option<&str>,
|
||||
homeserver: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentMatrixToken {
|
||||
agent_name: agent_name.to_owned(),
|
||||
token: token.to_owned(),
|
||||
account: account.map(ToOwned::to_owned),
|
||||
homeserver: homeserver.map(ToOwned::to_owned),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Write a GitHub personal access token (PAT) for `agent_name` via hive-priv
|
||||
/// (running as root). Writes `<state>/github-token` 0600, chowned to the agent
|
||||
/// user so the `gh` wrapper / git credential helper can read it from inside the
|
||||
|
|
|
|||
|
|
@ -207,22 +207,8 @@ async fn drain_swarm_events(
|
|||
return;
|
||||
}
|
||||
};
|
||||
// Also this hive's own, and for a second reason on top of the deploy
|
||||
// subject's: the payload names an agent in *this* hive's state dir, so a
|
||||
// notice for another hive is not merely noise, it is unactionable here.
|
||||
let credential_subject = swarm_queue_client::credential_subject(&hive);
|
||||
let mut credential_sub = match client.subscribe(credential_subject.clone()).await {
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subject = %credential_subject, error = %e,
|
||||
"swarm events: subscribe failed; this hive will not hear credential notices"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
%subject, %deploy_subject, %credential_subject,
|
||||
%subject, %deploy_subject,
|
||||
"swarm events: listening"
|
||||
);
|
||||
|
||||
|
|
@ -254,13 +240,6 @@ async fn drain_swarm_events(
|
|||
};
|
||||
handle_deploy_request(&coord, &msg.payload).await;
|
||||
}
|
||||
msg = credential_sub.next() => {
|
||||
let Some(msg) = msg else {
|
||||
tracing::warn!(subject = %credential_subject, "swarm events: credential subscription closed");
|
||||
return;
|
||||
};
|
||||
handle_credential_notice(&hive, &msg.payload).await;
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("swarm events: shutdown signal received");
|
||||
return;
|
||||
|
|
@ -274,39 +253,6 @@ async fn drain_swarm_events(
|
|||
///
|
||||
/// A payload that will not decode is worth a `warn`: the controller and this
|
||||
/// end share one type, so a decode failure means they disagree about it.
|
||||
/// Deliver the credential a [`swarm_queue_client::CredentialNotice`] names.
|
||||
///
|
||||
/// `hive` doubles as the cert-auth role this hive logs into the store as:
|
||||
/// `glue-bao-tls.nix` mints the client certificate with the hive name as its
|
||||
/// CN, and a bao cert role matches on CN — so the two share a name by
|
||||
/// construction rather than by convention.
|
||||
///
|
||||
/// ⚠️ Nothing here can log the secret, and that is structural rather than
|
||||
/// careful: the notice carries only names, and `deliver` writes the value
|
||||
/// without returning it.
|
||||
async fn handle_credential_notice(hive: &str, payload: &[u8]) {
|
||||
let notice: swarm_queue_client::CredentialNotice = match serde_json::from_slice(payload) {
|
||||
Ok(notice) => notice,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "swarm events: undecodable credential notice");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::workers::credential::deliver(¬ice, hive).await {
|
||||
// Warn rather than retry: the controller republishes, and a hive that
|
||||
// spun here would hold the queue task off its other two subjects.
|
||||
tracing::warn!(
|
||||
agent = %notice.agent, account = %notice.account, error = ?e,
|
||||
"swarm events: credential delivery failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
agent = %notice.agent, account = %notice.account,
|
||||
"swarm events: credential delivered"
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_deploy_request(
|
||||
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
|
||||
payload: &[u8],
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
//! Delivering an agent's external-account credential from the swarm's secret
|
||||
//! store into that agent's own state dir.
|
||||
//!
|
||||
//! The controller publishes a [`CredentialNotice`] naming an agent and an
|
||||
//! account; this reads the value out of the store and hands it to `hive-priv`,
|
||||
//! which writes it where the agent's matrix daemon already watches. The write
|
||||
//! goes through the privileged helper because the file lands in a directory
|
||||
//! owned by the agent and has to be chowned to it — written from here it
|
||||
//! arrives owned by `hive-core` and the agent cannot read its own credential.
|
||||
//! `hive-priv` also builds the filename, so nothing in this module decides it.
|
||||
//!
|
||||
//! Nothing here activates anything: `nix/agent-modules/matrix.nix` has a
|
||||
//! `systemd.paths` unit globbing `matrix-token*` inside that agent's own state
|
||||
//! dir, which re-fires the daemon when a token appears, so arrival is the
|
||||
//! whole trigger.
|
||||
//!
|
||||
//! 🔑 The notice carries no secret — see [`swarm_queue_client::credential_subject`]
|
||||
//! for why that is a requirement rather than a preference. The value is read
|
||||
//! from the store under this hive's own identity.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_types::Ident;
|
||||
use swarm_queue_client::CredentialNotice;
|
||||
use swarm_secret_client::{SecretStore, matrix};
|
||||
|
||||
/// Read the credential `notice` names and write it into the agent's state dir.
|
||||
///
|
||||
/// `cert_role` is the role on the store's `cert` auth mount whose policy scopes
|
||||
/// what this hive may read.
|
||||
///
|
||||
/// # Errors
|
||||
/// The store refusing, being unreachable, or holding nothing at that path; a
|
||||
/// name that is not a single path segment; or the write failing.
|
||||
pub async fn deliver(notice: &CredentialNotice, cert_role: &str) -> Result<()> {
|
||||
// Parsed before anything is read, so a malformed name costs a decode and
|
||||
// not a round trip to the store.
|
||||
let agent = Ident::parse(¬ice.agent)
|
||||
.map_err(|e| anyhow::anyhow!("agent name {:?} off the queue: {e}", notice.agent))?;
|
||||
let secret_path = matrix::account_path(¬ice.agent, ¬ice.account)
|
||||
.context("building the credential's path in the store")?;
|
||||
|
||||
let store = SecretStore::from_env(cert_role)
|
||||
.await
|
||||
.context("connecting to the swarm secret store")?;
|
||||
let credential: matrix::Credential = store
|
||||
.read(&secret_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {secret_path} from the store"))?;
|
||||
|
||||
// Through hive-priv rather than writing here: the file lands in a directory
|
||||
// owned by the agent, and only root can chown it there. Written directly it
|
||||
// arrives owned by `hive-core` at 0600 — the daemon wakes on it appearing
|
||||
// and cannot read it. hive-priv also builds the filename, so the name the
|
||||
// watcher globs for is decided in one place now.
|
||||
//
|
||||
// A homeserver is passed through when the stored credential carries one;
|
||||
// hive-priv then writes the `matrix-account-<name>.json` sidecar beside the
|
||||
// token, which is how the daemon discovers an extra account's homeserver
|
||||
// without a static `matrixAccounts` entry. Credentials written before that
|
||||
// field existed carry `None`, and the sidecar is simply not written — the
|
||||
// account then needs a configured entry, exactly as it did before.
|
||||
crate::priv_client::write_agent_matrix_token(
|
||||
agent.as_str(),
|
||||
&credential.value,
|
||||
Some(¬ice.account),
|
||||
credential.homeserver.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_name_that_could_address_another_agent_is_refused() {
|
||||
// `matrix::account_path` owns this rule; asserted here because this is
|
||||
// the module that feeds it names off the wire.
|
||||
assert!(matrix::account_path("../argus", "ccc").is_err());
|
||||
assert!(matrix::account_path("dmatrix", "../../etc/x").is_err());
|
||||
assert!(matrix::account_path("dmatrix", "ccc").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_name_off_the_queue_must_pass_the_ident_parser_too() {
|
||||
// Two independent refusals, not one restated: `matrix::account_path`
|
||||
// guards the address in the *store*, and `Ident` guards the name this
|
||||
// module hands to hive-priv, which builds the on-disk path from it.
|
||||
for bad in ["../argus", "dmatrix/../argus", "Dmatrix", "d matrix", ""] {
|
||||
assert!(Ident::parse(bad).is_err(), "{bad:?} must be refused");
|
||||
}
|
||||
// The control: without it, a parser that rejected everything would
|
||||
// satisfy the loop above.
|
||||
assert!(Ident::parse("dmatrix").is_ok());
|
||||
}
|
||||
|
||||
/// The account half of that same rule now lives where the filename is
|
||||
/// built — `hive-priv`'s `validate_account_name`, asserted there with the
|
||||
/// same arms and the same two controls. It is not restated here because
|
||||
/// this module no longer builds the path.
|
||||
#[test]
|
||||
fn an_account_name_off_the_queue_is_refused_for_the_store_path() {
|
||||
for bad in ["../argus", "a/b", "a b", ""] {
|
||||
assert!(
|
||||
matrix::account_path("dmatrix", bad).is_err(),
|
||||
"account {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
// Controls: the legal charset stays reachable, so the loop above is not
|
||||
// passing because everything is refused. Uppercase and underscore are
|
||||
// deliberate — `matrixAccounts` is an attrset, so both are names an
|
||||
// operator can already write, and hive-priv must accept them too.
|
||||
assert!(matrix::account_path("dmatrix", "ops-relay").is_ok());
|
||||
assert!(matrix::account_path("dmatrix", "Ops_Relay9").is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
pub mod agent_sockets;
|
||||
pub mod auto_update;
|
||||
pub mod crash_watch;
|
||||
pub mod credential;
|
||||
pub mod knowledge;
|
||||
pub mod mcp_sockets;
|
||||
pub mod scheduled_prompts_worker;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -479,40 +479,11 @@ pub enum PrivRequest {
|
|||
},
|
||||
|
||||
// --- Agent credential writes ---
|
||||
/// Write a matrix access token into the agent's state dir. With
|
||||
/// `account: None` it targets the hive-internal `matrix-token`; with
|
||||
/// `account: Some(name)` it targets `matrix-token-<name>` for an extra
|
||||
/// (external) account. hive-priv validates both `agent_name` and the
|
||||
/// `account` suffix as plain identifiers before building the path, so a
|
||||
/// crafted account name cannot traverse out of the state dir.
|
||||
///
|
||||
/// hive-priv validates the names, creates the state dir if absent,
|
||||
/// writes the file 0600, and chowns it to the state dir's owner so the
|
||||
/// agent process can read it. Required because hive-c0re runs
|
||||
/// unprivileged and cannot write to agent-owned state directories.
|
||||
WriteAgentMatrixToken {
|
||||
/// Logical agent name (validated by `validate_agent_name`).
|
||||
agent_name: String,
|
||||
/// Token value. hive-priv appends a trailing newline before writing.
|
||||
token: String,
|
||||
/// Extra-account suffix. `None` → `matrix-token` (the hive account);
|
||||
/// `Some(name)` → `matrix-token-<name>` (validated as a plain ident).
|
||||
account: Option<String>,
|
||||
/// Homeserver URL for an extra account. When `Some` (only meaningful
|
||||
/// alongside `account: Some`), hive-priv also writes the sidecar
|
||||
/// `matrix-account-<name>.json` (`{"homeserver": <url>}`, 0600,
|
||||
/// chowned to the agent) so the daemon can auto-discover the account
|
||||
/// without a config declaration. `None` → no sidecar written.
|
||||
#[serde(default)]
|
||||
homeserver: Option<String>,
|
||||
},
|
||||
|
||||
/// Write `github-token` into `AGENT_STATE_ROOT/<agent_name>/state/github-token`.
|
||||
///
|
||||
/// The operator-supplied GitHub personal access token (PAT) for the
|
||||
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`). Same write
|
||||
/// semantics as
|
||||
/// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir
|
||||
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`).
|
||||
/// hive-priv validates `agent_name`, creates the state dir
|
||||
/// if absent, writes the file 0600, and chowns it to the agent so the
|
||||
/// `gh` wrapper / git credential helper can read it. No account suffix
|
||||
/// (single GitHub account per agent).
|
||||
|
|
@ -528,15 +499,13 @@ pub enum PrivRequest {
|
|||
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
|
||||
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
|
||||
/// the base URL survives without any host-side nix config — the whole
|
||||
/// account (label + URL + token) is operator-entered on the dashboard,
|
||||
/// same shape as `WriteAgentMatrixToken`'s homeserver sidecar.
|
||||
/// account (label + URL + token) is operator-entered on the dashboard.
|
||||
///
|
||||
/// `label` MUST be validated as a plain identifier (same rule as the
|
||||
/// matrix `account` suffix) before it goes into the filename — a
|
||||
/// crafted label could otherwise traverse out of the state dir. Same
|
||||
/// write semantics as `WriteAgentMatrixToken` — validates `agent_name`,
|
||||
/// creates the state dir if absent, writes both files 0600, chowns to
|
||||
/// the agent.
|
||||
/// `label` MUST be validated as a plain identifier before it goes into
|
||||
/// the filename — a crafted label could otherwise traverse out of the
|
||||
/// state dir. Same write semantics as `WriteAgentGithubToken` — validates
|
||||
/// `agent_name`, creates the state dir if absent, writes both files 0600,
|
||||
/// chowns to the agent.
|
||||
WriteAgentExtraForgeAccount {
|
||||
/// Logical agent name (validated by `validate_agent_name`).
|
||||
agent_name: String,
|
||||
|
|
|
|||
|
|
@ -408,13 +408,6 @@ async fn exec(
|
|||
paused,
|
||||
} => exec_set_agent_paused(agent_name, paused),
|
||||
|
||||
PrivRequest::WriteAgentMatrixToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
ref account,
|
||||
ref homeserver,
|
||||
} => write_matrix_token(agent_name, token, account.as_deref(), homeserver.as_deref()),
|
||||
|
||||
PrivRequest::WriteAgentGithubToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
|
|
@ -530,34 +523,6 @@ async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> {
|
|||
run_forge_admin(args).await
|
||||
}
|
||||
|
||||
/// `WriteAgentMatrixToken`: `account = None` writes the hive account's
|
||||
/// `matrix-token`; `Some(a)` writes `matrix-token-<a>`. The account suffix
|
||||
/// MUST be validated as a plain identifier (no `/`, `.`, `..`) before it goes
|
||||
/// into the filename, or a crafted account could traverse out of the state
|
||||
/// dir — `write_agent_state_file` trusts its `filename` argument. When both
|
||||
/// `account` and `homeserver` are `Some`, also persists a
|
||||
/// `matrix-account-<a>.json` sidecar so the daemon can auto-discover the
|
||||
/// extra account without a static `matrixAccounts` config entry.
|
||||
fn write_matrix_token(
|
||||
agent_name: &str,
|
||||
token: &str,
|
||||
account: Option<&str>,
|
||||
homeserver: Option<&str>,
|
||||
) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
if let Some(a) = account {
|
||||
validate_account_name(a)?;
|
||||
}
|
||||
let filename = matrix_token_filename(account);
|
||||
let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?;
|
||||
if let (Some(a), Some(hs)) = (account, homeserver) {
|
||||
let meta = serde_json::to_string(&MatrixAccountSidecar { homeserver: hs })
|
||||
.context("serialize matrix account sidecar")?;
|
||||
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `WriteAgentExtraForgeAccount`: writes the token, then a
|
||||
/// `forge-<label>.json` sidecar carrying the base URL — there's no
|
||||
/// host-side nix config for extra forges, so this is the only place it's
|
||||
|
|
@ -1405,27 +1370,12 @@ fn ensure_plain_filename(who: &str, filename: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Basename an agent's matrix token is written under — `matrix-token` for the
|
||||
/// hive's own account, `matrix-token-<account>` for an extra one.
|
||||
///
|
||||
/// Half of an agreement whose other half is a `systemd.paths` glob in
|
||||
/// `nix/agent-modules/matrix.nix`, which no test here can reach: a name that
|
||||
/// stopped matching `matrix-token*` would land a credential the agent's daemon
|
||||
/// never wakes for — no error, just a token that silently never arrives.
|
||||
fn matrix_token_filename(account: Option<&str>) -> String {
|
||||
match account {
|
||||
None => "matrix-token".to_owned(),
|
||||
Some(a) => format!("matrix-token-{a}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the content is written under before being renamed onto `filename`,
|
||||
/// unique per call so two concurrent writes of one file never share a temp.
|
||||
///
|
||||
/// The leading dot and the `.partial` suffix are load-bearing:
|
||||
/// `nix/agent-modules/matrix.nix` starts the agent's matrix daemon on the glob
|
||||
/// `matrix-token*`, and systemd reads only `*.conf` out of `tmpfiles.d` and
|
||||
/// drop-in dirs, so none of them can pick up a half-written temp.
|
||||
/// The leading dot and the `.partial` suffix are load-bearing: a reader
|
||||
/// globbing on a published name's prefix, or systemd reading `*.conf` out of
|
||||
/// `tmpfiles.d` and drop-in dirs, cannot pick up a half-written temp.
|
||||
fn partial_name(filename: &str) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -1581,20 +1531,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
|
|||
staged.publish()
|
||||
}
|
||||
|
||||
/// Sidecar written alongside an extra matrix account's token
|
||||
/// (`matrix-account-<name>.json`) so `hive-matrix-mcp` can auto-discover
|
||||
/// the account's homeserver without a static `matrixAccounts` config
|
||||
/// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s
|
||||
/// `read_account_homeserver` (deliberately reads via a bare
|
||||
/// `serde_json::Value` rather than this shape — that side treats a
|
||||
/// malformed/missing sidecar as "skip this account" rather than an
|
||||
/// error, so it stays loosely typed; this side is the one place the
|
||||
/// file is written, so it gets the precise shape).
|
||||
#[derive(Serialize)]
|
||||
struct MatrixAccountSidecar<'a> {
|
||||
homeserver: &'a str,
|
||||
}
|
||||
|
||||
/// Sidecar written alongside a dashboard-provisioned extra forge
|
||||
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
|
||||
/// the account's base URL. Read side: `hive-forge/src/client.rs`'s own
|
||||
|
|
@ -3051,29 +2987,6 @@ fn validate_name_chars(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a matrix account name, which is a wider charset than
|
||||
/// [`validate_name_chars`] allows on purpose.
|
||||
///
|
||||
/// An agent name is an `Ident` and lowercase by design. An account name is an
|
||||
/// attribute name in `services.hyperhive.agent.matrixAccounts`, typed `attrsOf` with no
|
||||
/// charset constraint, so `Ops_Relay9` is a key an operator may already have
|
||||
/// written. `swarm_secret_client::path::checked_segment` accepts exactly this
|
||||
/// set for the same name in the secret store — the two must agree, or a
|
||||
/// credential reads out of the store and then fails to land on disk.
|
||||
///
|
||||
/// Still a single plain component: no `/`, no `.`, no whitespace, so it cannot
|
||||
/// climb out of the agent's state dir or name the dir itself.
|
||||
fn validate_account_name(name: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
bail!("invalid account name {name:?}: must be non-empty [A-Za-z0-9_-]");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a bind-mount path: must be absolute, non-empty, and contain
|
||||
/// no newlines, null bytes, double-quotes, or colons.
|
||||
///
|
||||
|
|
@ -3393,10 +3306,10 @@ mod tests {
|
|||
BindMount, BoundedRun, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile,
|
||||
check_fd_agreement, clear_runner_credentials, contains_secret_shaped_run,
|
||||
describe_forge_admin, ensure_plain_filename, ensure_socket_dir_in, git_overlay_flags,
|
||||
limits_dropin_body, matrix_token_filename, open_dir, open_export_dest, partial_name,
|
||||
publish_file, redact_secret_line, remove_marker_in, run_bounded, single_output_path,
|
||||
toplevel_attr, validate_account_name, validate_credential_name, validate_snapshot_name,
|
||||
write_agent_dir_file, write_bridge_dns_marker_in,
|
||||
limits_dropin_body, open_dir, open_export_dest, partial_name, publish_file,
|
||||
redact_secret_line, remove_marker_in, run_bounded, single_output_path, toplevel_attr,
|
||||
validate_credential_name, validate_snapshot_name, write_agent_dir_file,
|
||||
write_bridge_dns_marker_in,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
|
@ -3780,39 +3693,6 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Ported from `hive-c0re`'s `credential.rs`, which used to build this path
|
||||
/// itself. The controls are the load-bearing half: an account name is an
|
||||
/// attrset key in `services.hyperhive.agent.matrixAccounts`, so uppercase and underscore
|
||||
/// are names an operator can already have written, and the secret store
|
||||
/// accepts exactly this set for the same name. A validator narrower than
|
||||
/// the store's reads a credential out and then refuses to land it.
|
||||
#[test]
|
||||
fn an_account_name_is_checked_against_the_same_charset_the_store_uses() {
|
||||
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
|
||||
assert!(
|
||||
validate_account_name(bad).is_err(),
|
||||
"account {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
assert!(validate_account_name("ops-relay").is_ok());
|
||||
assert!(validate_account_name("Ops_Relay9").is_ok());
|
||||
}
|
||||
|
||||
/// Pinned here because here is where the name is decided. `hive-c0re` used
|
||||
/// to assert this against a path helper of its own, which stopped deciding
|
||||
/// anything the moment credential delivery started routing through this
|
||||
/// process — a test that would have kept passing while the real filename
|
||||
/// drifted.
|
||||
#[test]
|
||||
fn a_matrix_token_is_named_for_the_glob_the_daemon_watches() {
|
||||
assert_eq!(matrix_token_filename(None), "matrix-token");
|
||||
assert_eq!(matrix_token_filename(Some("ccc")), "matrix-token-ccc");
|
||||
// And the temp the publish goes through must not match that same glob,
|
||||
// which is only checkable now that both names are built in one place.
|
||||
let tmp = partial_name(&matrix_token_filename(Some("ccc")));
|
||||
assert!(!tmp.starts_with("matrix-token"), "got {tmp}");
|
||||
}
|
||||
|
||||
/// The temp name is the whole reason the rename is safe to watch: a
|
||||
/// `systemd.path` unit globbing `matrix-token*` would fire on a temp that
|
||||
/// merely suffixed the real name, on exactly the empty file the rename
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@
|
|||
}:
|
||||
let
|
||||
userName = config.services.hyperhive.agent.user.name;
|
||||
# This agent's own state dir, where a hive used to write the `main`
|
||||
# account's token (`matrix-token`, now the store's fallback) and the
|
||||
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
|
||||
# below and the path-watcher glob at the bottom of this file.
|
||||
# This agent's own state dir: `main`'s fallback token file (`matrix-token`)
|
||||
# and the daemon's matrix-sdk stores. 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
|
||||
|
|
@ -390,6 +389,11 @@ in
|
|||
# non-zero exit, which `on-failure` already restarts.
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
# The daemon exits 75 (`ACCOUNTS_CHANGED_EXIT` in
|
||||
# hive-matrix-mcp/src/main.rs) when the store's linked accounts change:
|
||||
# a clean exit that must still restart it onto the new set.
|
||||
RestartForceExitStatus = "75";
|
||||
SuccessExitStatus = "75";
|
||||
User = userName;
|
||||
Group = userName;
|
||||
}
|
||||
|
|
@ -416,18 +420,16 @@ in
|
|||
timerConfig.OnUnitInactiveSec = "5min";
|
||||
};
|
||||
|
||||
# Re-fire the daemon when the matrix token appears (a token
|
||||
# file: an extra account the hive delivers, or a `main` from before the
|
||||
# swarm minted it). Without this
|
||||
# Re-fire the daemon when a matrix token file appears (a declared
|
||||
# `tokenFile`, 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).
|
||||
systemd.paths.hive-matrix-daemon = lib.mkIf matrixEnabled {
|
||||
description = "trigger hive-matrix-daemon when a matrix token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# `matrix-token*` (not just `matrix-token`) so a secondary
|
||||
# multi-account token (e.g. `matrix-token-ccc`) landing also
|
||||
# re-fires the daemon to pick up the freshly-provisioned account.
|
||||
# `matrix-token*` (not just `matrix-token`) so a declared secondary
|
||||
# token file (e.g. `matrix-token-ccc`) landing also re-fires the daemon.
|
||||
#
|
||||
# ⚠️ This agent's own state dir, not a glob over `/agents/*/`. Every
|
||||
# agent's state dir is visible from inside every container, so a
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ in
|
|||
}
|
||||
//
|
||||
# Where the swarm's secret store is, and the identity this hive presents to
|
||||
# it (hive-c0re::workers::credential). `swarm_secret_client` reads these
|
||||
# it (hive-c0re::lifecycle::agent_identity). `swarm_secret_client` reads these
|
||||
# spellings explicitly rather than vaultrs's `VAULT_*` defaults — falling
|
||||
# through to those would build a client with no identity and fail at the TLS
|
||||
# handshake, naming neither.
|
||||
|
|
|
|||
|
|
@ -8,24 +8,17 @@
|
|||
//!
|
||||
//! **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.
|
||||
//! in the swarm's secret store.
|
||||
//!
|
||||
//! 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
|
||||
//! notice carries only names, so the queue never holds the secret — see
|
||||
//! [`swarm_queue_client::credential_subject`] for why that is a requirement
|
||||
//! rather than a preference.
|
||||
//!
|
||||
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
|
||||
//! that overtakes its own write reaches a hive that reads nothing, and the
|
||||
//! hive deliberately does not retry.
|
||||
//! The agent end is `hive-matrix-daemon`, which lists the agent's accounts and
|
||||
//! reads each under the agent's own certificate. No hive is in the path.
|
||||
//!
|
||||
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
|
||||
//! (default, back-compat with the original blind-store shape — the caller
|
||||
//! already has a bearer token) and `password` (this daemon performs
|
||||
//! `m.login.password` against the caller-given homeserver itself and stores
|
||||
//! the resulting token; the password is never stored, and is not sent to the
|
||||
//! hive either — only the derived token is). Done here so the browser never
|
||||
//! the resulting token; the password is never stored — only the derived token
|
||||
//! is). Done here so the browser never
|
||||
//! has to hold the password long enough to call an arbitrary homeserver
|
||||
//! directly.
|
||||
|
||||
|
|
@ -33,7 +26,6 @@ use axum::Json;
|
|||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_queue_client::{CredentialNotice, credential_subject};
|
||||
use swarm_secret_client::matrix;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
|
|
@ -103,11 +95,8 @@ pub struct PutMatrixAccountRequest {
|
|||
/// Password-mode only. Never logged and never stored — only the token
|
||||
/// `m.login.password` returns is.
|
||||
password: Option<String>,
|
||||
/// The account's homeserver, when it is not this swarm's own.
|
||||
///
|
||||
/// Stored beside the token rather than sent on the notice: a notice is a
|
||||
/// queue message, so a homeserver carried there would exist only in
|
||||
/// flight, with nowhere to reconstruct it from on a re-delivery.
|
||||
/// The account's homeserver, when it is not this swarm's own. Stored
|
||||
/// beside the token, which is where the agent's daemon reads it.
|
||||
///
|
||||
/// Optional in token mode (omitted means "resolve to the agent's own
|
||||
/// `services.hyperhive.agent.matrix.url` on the hive side" — this route never needs to
|
||||
|
|
@ -128,7 +117,7 @@ pub struct PutMatrixAccountResponse {
|
|||
user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Store an agent's external matrix account credential and notify its hive.
|
||||
/// Store an agent's external matrix account credential.
|
||||
///
|
||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||
/// value the agent will next read rather than adding a second account.
|
||||
|
|
@ -136,16 +125,15 @@ pub struct PutMatrixAccountResponse {
|
|||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive whose agent receives the credential"),
|
||||
("agent" = String, Path, description = "agent the credential is delivered to"),
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the credential belongs to"),
|
||||
("account" = String, Path, description = "the external account this credential authenticates as"),
|
||||
),
|
||||
request_body = PutMatrixAccountRequest,
|
||||
responses(
|
||||
(status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
|
||||
(status = 200, description = "stored", body = PutMatrixAccountResponse),
|
||||
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 503, description = "no swarm queue is wired up, or it is not connected (problem+json)", body = String),
|
||||
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
|
||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
|
|
@ -154,15 +142,6 @@ pub async fn put_matrix_account(
|
|||
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
||||
Json(req): Json<PutMatrixAccountRequest>,
|
||||
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
|
||||
// The queue first, so a deployment that has none answers 503 whatever the
|
||||
// caller spelled — and before the store is touched, so a request that
|
||||
// could never be delivered does not leave a credential behind.
|
||||
let Some(status) = state.status.as_ref() else {
|
||||
return Err(error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"this deployment wired up no swarm queue, so there is no hive to notify",
|
||||
));
|
||||
};
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
|
|
@ -191,21 +170,6 @@ pub async fn put_matrix_account(
|
|||
// touched, so a failed login leaves no partial state behind.
|
||||
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
|
||||
|
||||
// A queue that is configured but not yet (or no longer) connected does
|
||||
// not fail the `publish`/`flush` further down outright — it *hangs*
|
||||
// them, per `swarm_queue_client::ensure_connected`'s own doc. Checked
|
||||
// here, immediately before the store is touched, so a request that
|
||||
// cannot be delivered never leaves a credential behind — the same
|
||||
// ordering rule the module doc states for "store first, notify
|
||||
// second", extended one step earlier.
|
||||
let client = status.queue_client();
|
||||
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&swarm_queue_client::chain(&e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let store = crate::store::connect().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
|
|
@ -225,37 +189,7 @@ pub async fn put_matrix_account(
|
|||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
let notice = CredentialNotice {
|
||||
agent: agent.clone(),
|
||||
account: account.clone(),
|
||||
};
|
||||
let payload = serde_json::to_vec(¬ice).map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("encoding the credential notice failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
let subject = credential_subject(&hive);
|
||||
client
|
||||
.publish(subject.clone(), payload.into())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("publishing to {subject} failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
// Flushed for the reason `publish_deploy` flushes: `publish` hands the
|
||||
// message to the connection's write buffer and returns, so without this
|
||||
// the response can outrun the notice it reports as sent.
|
||||
client.flush().await.map_err(|e| {
|
||||
error_problem(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("flushing the credential notice to {subject} failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
|
||||
tracing::info!(%hive, %agent, %account, "credential stored");
|
||||
Ok(Json(PutMatrixAccountResponse { user_id }))
|
||||
}
|
||||
|
||||
|
|
@ -544,32 +478,17 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// ── the connected-but-not-yet-connected queue ───────────────────────
|
||||
|
||||
/// A client that exists but has never connected — the `Pending` state
|
||||
/// `ensure_connected`'s own doc says a `Disconnected` check would miss.
|
||||
/// `retry_on_initial_connect` is what makes `.connect()` return
|
||||
/// immediately instead of blocking on a handshake that will never
|
||||
/// succeed against a loopback port nothing listens on.
|
||||
async fn disconnected_client() -> async_nats::Client {
|
||||
async_nats::ConnectOptions::new()
|
||||
.retry_on_initial_connect()
|
||||
.connect("127.0.0.1:1")
|
||||
.await
|
||||
.expect("retry_on_initial_connect returns without waiting for a real connection")
|
||||
}
|
||||
|
||||
/// Bare-minimum `AppState` for a handler test: one hive, no queue-backed
|
||||
/// helpers beyond `status` (the field this handler actually reads), and
|
||||
/// an empty in-memory job graph the endpoint under test never touches.
|
||||
fn state_with_status(status: super::super::status::StatusReader) -> super::super::AppState {
|
||||
/// Bare-minimum `AppState` for a handler test: one hive, nothing
|
||||
/// queue-backed, and an empty in-memory job graph the endpoint under test
|
||||
/// never touches.
|
||||
fn state() -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
}]),
|
||||
links: std::sync::Arc::new(Vec::new()),
|
||||
status: Some(std::sync::Arc::new(status)),
|
||||
status: None,
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
|
|
@ -586,35 +505,44 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The defect this whole PR exists to close: a queue that is
|
||||
/// configured but not connected must not let this handler reach the
|
||||
/// store write at all.
|
||||
///
|
||||
/// `BAO_ADDR`/`BAO_CLIENT_CERT`/`BAO_CLIENT_KEY` are asserted unset
|
||||
/// first — not incidental setup, but the control that makes the 503
|
||||
/// meaningful. If `put_matrix_account` reached `crate::store::connect()`
|
||||
/// with those unset, *that* call fails too, and would also answer with
|
||||
/// a `problem+json` body (500, "connecting to the swarm secret store
|
||||
/// failed"). A 503 here is therefore proof execution never got past
|
||||
/// `ensure_connected`, not a coincidence of two paths landing on the
|
||||
/// same status family.
|
||||
/// Refused before the store is reached: with `BAO_*` unset a store
|
||||
/// connect would answer 500, so a 400 is the reserved-name check.
|
||||
#[tokio::test]
|
||||
async fn a_disconnected_queue_answers_503_and_never_reaches_the_store() {
|
||||
async fn a_reserved_account_name_is_refused_before_the_store() {
|
||||
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
|
||||
assert!(
|
||||
std::env::var(var).is_err(),
|
||||
"{var} must be unset for this test to prove anything"
|
||||
);
|
||||
}
|
||||
|
||||
let reader = super::super::status::StatusReader::new(
|
||||
disconnected_client().await,
|
||||
std::time::Duration::from_mins(1),
|
||||
);
|
||||
let state = state_with_status(reader);
|
||||
|
||||
let result = super::put_matrix_account(
|
||||
axum::extract::State(state),
|
||||
axum::extract::State(state()),
|
||||
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())),
|
||||
axum::Json(PutMatrixAccountRequest {
|
||||
mode: "token".to_owned(),
|
||||
token: Some("t0k3n".to_owned()),
|
||||
user_id: None,
|
||||
password: None,
|
||||
homeserver: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let problem = result.expect_err("'main' is reserved");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control for the test above: an ordinary name gets past every check
|
||||
/// and fails at the store connect, so the 400 above is not what every call
|
||||
/// answers.
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_account_name_reaches_the_store() {
|
||||
let result = super::put_matrix_account(
|
||||
axum::extract::State(state()),
|
||||
axum::extract::Path((
|
||||
"pr1ma".to_owned(),
|
||||
"atlas".to_owned(),
|
||||
|
|
@ -630,48 +558,10 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
|
||||
let problem = result.expect_err("a disconnected queue must refuse, not hang or 500");
|
||||
let problem = result.expect_err("no store is configured in a test");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control for the test above: a client that starts in `Pending`
|
||||
/// but genuinely never connects is exactly what `ensure_connected` is
|
||||
/// specified to reject — proving the 503 above tracks the connection
|
||||
/// state and is not simply what every call through this handler
|
||||
/// returns. Same client shape, mode rejected before either queue or
|
||||
/// store is touched, so it exercises a different early return
|
||||
/// (`is_reserved_account`) and confirms the handler still validates
|
||||
/// normally on a path that never reaches `ensure_connected`'s sibling
|
||||
/// checks.
|
||||
#[tokio::test]
|
||||
async fn a_reserved_account_name_is_still_refused_before_any_queue_check_matters() {
|
||||
let reader = super::super::status::StatusReader::new(
|
||||
disconnected_client().await,
|
||||
std::time::Duration::from_mins(1),
|
||||
);
|
||||
let state = state_with_status(reader);
|
||||
|
||||
let result = super::put_matrix_account(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())),
|
||||
axum::Json(PutMatrixAccountRequest {
|
||||
mode: "token".to_owned(),
|
||||
token: Some("t0k3n".to_owned()),
|
||||
user_id: None,
|
||||
password: None,
|
||||
homeserver: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let problem = result.expect_err("'main' is reserved regardless of queue state");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,17 +498,6 @@ impl Policy {
|
|||
// admission). Same failure mode as the knowledge event above: a
|
||||
// refused publish reaches the client as a timeout.
|
||||
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
|
||||
// The credential notices: same per-hive family and same wildcard
|
||||
// reasoning as the deploy events, and the same timeout-not-error
|
||||
// failure if this line is missing.
|
||||
//
|
||||
// 🔑 Worth being explicit that this grant is not a confidentiality
|
||||
// boundary, because it looks like one. `sub` is unrestricted, so
|
||||
// any hive could subscribe to another's notices — which is exactly
|
||||
// why the payload names a credential and never carries one. What
|
||||
// scopes the secret is the store's own policy at read time, under
|
||||
// the reading hive's certificate.
|
||||
swarm_queue_client::CREDENTIAL_SUBJECT_WILDCARD.to_owned(),
|
||||
// The wanted-state buckets — one per hive, all created and written
|
||||
// by this single client.
|
||||
//
|
||||
|
|
@ -759,36 +748,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reader_may_publish_a_credential_notice_to_any_hive() {
|
||||
let p = policy().permissions("swarm-controller").expect("a reader");
|
||||
assert!(
|
||||
p.publish
|
||||
.contains(&swarm_queue_client::CREDENTIAL_SUBJECT_WILDCARD.to_owned()),
|
||||
"without this grant the controller's publish is refused, and a \
|
||||
refusal arrives as a timeout — a hive that silently never receives \
|
||||
a credential, with nothing in either log saying why"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_may_not_publish_a_credential_notice() {
|
||||
// A forged notice cannot leak a secret — the payload carries none — but
|
||||
// it can make a hive fetch and overwrite an agent's token file with
|
||||
// whatever the store holds for a name the forger chose.
|
||||
let p = policy()
|
||||
.permissions("hive-alpha")
|
||||
.expect("a hive is admitted");
|
||||
assert!(
|
||||
!p.publish
|
||||
.iter()
|
||||
.any(|s| s == swarm_queue_client::CREDENTIAL_SUBJECT_WILDCARD
|
||||
|| s == &swarm_queue_client::credential_subject("hive-alpha")),
|
||||
"a hive must not publish credential notices, its own included: {:?}",
|
||||
p.publish
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_may_not_publish_a_deploy_event_to_anyone_including_itself() {
|
||||
// Same arm as the knowledge event's, and it matters more here: a forged
|
||||
|
|
|
|||
|
|
@ -221,42 +221,6 @@ pub const DEPLOY_SUBJECT_WILDCARD: &str = "$SWARM.deploy.*";
|
|||
/// cannot drift into naming different families.
|
||||
const DEPLOY_SUBJECT_PREFIX: &str = "$SWARM.deploy";
|
||||
|
||||
/// The subject the controller publishes on to tell `hive` that a credential
|
||||
/// for one of its agents is waiting in the secret store. Same per-hive family
|
||||
/// as [`deploy_subject`], here for the same three-crate reason.
|
||||
///
|
||||
/// 🔑 **The message NAMES a credential and never carries one**, and the note
|
||||
/// on [`deploy_subject`] is why: the family split buys quiet, not
|
||||
/// confidentiality — the auth-callout responder scopes `pub` and leaves `sub`
|
||||
/// unrestricted, so any hive that wanted another's messages could subscribe to
|
||||
/// them. A secret in this payload would be readable swarm-wide. The hive reads
|
||||
/// the value from the store under its own identity instead, where the store's
|
||||
/// policy is the thing that actually scopes it.
|
||||
#[must_use]
|
||||
pub fn credential_subject(hive: &str) -> String {
|
||||
format!("{CREDENTIAL_SUBJECT_PREFIX}.{hive}")
|
||||
}
|
||||
|
||||
/// The publish grant covering every [`credential_subject`] — a wildcard for
|
||||
/// the same no-roster reason as [`DEPLOY_SUBJECT_WILDCARD`].
|
||||
pub const CREDENTIAL_SUBJECT_WILDCARD: &str = "$SWARM.credential.*";
|
||||
|
||||
/// Shared by [`credential_subject`] and [`CREDENTIAL_SUBJECT_WILDCARD`] so the
|
||||
/// two cannot drift into naming different families.
|
||||
const CREDENTIAL_SUBJECT_PREFIX: &str = "$SWARM.credential";
|
||||
|
||||
/// What a [`credential_subject`] message says: which agent's credential
|
||||
/// changed, and which account it belongs to. Deliberately the whole payload —
|
||||
/// anything more would be either derivable by the reader or a secret that
|
||||
/// must not be on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CredentialNotice {
|
||||
/// The agent whose state dir receives the credential.
|
||||
pub agent: String,
|
||||
/// The external account the credential authenticates as.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// What a [`deploy_subject`] message carries.
|
||||
///
|
||||
/// Only the agent: the subject already names the hive, and repeating it here
|
||||
|
|
|
|||
|
|
@ -202,6 +202,32 @@ impl SecretStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// The keys stored directly under `path`, or none when nothing is.
|
||||
///
|
||||
/// A key ending in `/` is a directory below `path` rather than an object;
|
||||
/// keys come back as the store spells them, so a caller wanting objects
|
||||
/// filters those out.
|
||||
///
|
||||
/// Addresses `secret/metadata/<path>`, which the store ACLs separately
|
||||
/// from the `secret/data/<path>` that [`read`][Self::read] uses: a token
|
||||
/// that may read every object under a path is refused here until its
|
||||
/// policy grants `list` on the metadata path too.
|
||||
///
|
||||
/// **Only a 404 is "none"**, as in [`read_optional`][Self::read_optional]:
|
||||
/// the store answers a `LIST` on an empty directory with a 404, and a
|
||||
/// denial stays an error.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
|
||||
/// unreachable store.
|
||||
pub async fn list(&self, path: &str) -> Result<Vec<String>, Error> {
|
||||
match vaultrs::kv2::list(&self.inner, MOUNT, path).await {
|
||||
Ok(keys) => Ok(keys),
|
||||
Err(e) if is_absent(&e) => Ok(Vec::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `value` at `path`, creating a new version.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -21,9 +21,18 @@ use crate::{
|
|||
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
||||
/// another agent's secret.
|
||||
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
checked_segment("account", account)?;
|
||||
Ok(format!("{prefix}/matrix/{account}"))
|
||||
Ok(format!("{}/{account}", accounts_dir(agent)?))
|
||||
}
|
||||
|
||||
/// The directory every one of `agent`'s [`account_path`]s is under, in the
|
||||
/// form [`crate::SecretStore::list`] takes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
|
||||
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/matrix"))
|
||||
}
|
||||
|
||||
/// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
|
||||
|
|
@ -117,10 +126,9 @@ pub fn swarm_appservice_token_path() -> Result<String, Error> {
|
|||
|
||||
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
||||
///
|
||||
/// The homeserver rides with the token rather than on the queue notice that
|
||||
/// triggers a delivery, because a notice is not persistence — re-delivering a
|
||||
/// credential has to reconstruct it, and the store is the only thing that keeps
|
||||
/// it.
|
||||
/// The homeserver rides with the token because the agent's daemon learns both
|
||||
/// from this one object: an account it finds by listing [`accounts_dir`] has
|
||||
/// no other place its homeserver is written down.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
/// `glue-matrix-bao-token.nix` reads the store with
|
||||
|
|
|
|||
Loading…
Reference in a new issue