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.
|
"this agent has no matrix" state.
|
||||||
|
|
||||||
**First-boot ordering**: a token can arrive after the container comes
|
**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
|
Without the path-trigger sibling
|
||||||
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
||||||
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
|
<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
|
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
|
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
|
of the token re-fire the service so the daemon comes alive in the
|
||||||
same boot cycle as
|
same boot cycle as
|
||||||
provisioning. A token in the store changes no file, so a store-backed agent
|
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
|
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
|
restart the daemon re-runs each account's bring-up, which sets the
|
||||||
avatar (see below).
|
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
|
its own `homeserver` is enough on its own; a hive homeserver isn't
|
||||||
required.
|
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
|
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
|
Declaring an extra account also changes what the agent *receives*: wake
|
||||||
bodies and invite todos gain an `[acct:<name>]` prefix — see
|
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 |
|
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||||
| `DaemonReload` | `systemctl daemon-reload` |
|
| `DaemonReload` | `systemctl daemon-reload` |
|
||||||
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
// (required, here) homeserver itself and stores the token
|
||||||
// that comes back. The password is sent once, over this
|
// that comes back. The password is sent once, over this
|
||||||
// PUT, straight to swarm-controller — never held here past
|
// 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}` —
|
// 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`,
|
// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
|
||||||
// same as every other form here, since the response is `problem+json`
|
// same as every other form here, since the response is `problem+json`
|
||||||
// regardless of which arm fired.
|
// regardless of which arm fired.
|
||||||
|
|
@ -138,9 +138,9 @@ export function LinkMatrixAccountForm({
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Writes the credential to the swarm secret store and notifies{" "}
|
Writes the credential to the swarm secret store. The agent's own matrix
|
||||||
<strong>{hive}</strong> to deliver it. The agent's own matrix daemon
|
daemon reads it from there within two minutes — nothing here restarts
|
||||||
picks it up next — nothing here restarts anything directly.
|
anything directly.
|
||||||
</p>
|
</p>
|
||||||
<form class="link-matrix-account-form" onSubmit={submit}>
|
<form class="link-matrix-account-form" onSubmit={submit}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,7 @@ enum Skipped {
|
||||||
async fn collect(agent: &str, dir: &Path) -> Result<(), 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`
|
// 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
|
// 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 —
|
// 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.
|
|
||||||
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
|
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
|
||||||
return Err(Skipped::NotConfigured(
|
return Err(Skipped::NotConfigured(
|
||||||
"HYPERHIVE_HIVE_NAME is unset, so this hive has no role to log in as",
|
"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.
|
/// store identity.
|
||||||
///
|
///
|
||||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
/// 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
|
/// 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
|
/// [`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:
|
/// 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?)
|
.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
|
/// 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
|
/// (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
|
/// user so the `gh` wrapper / git credential helper can read it from inside the
|
||||||
|
|
|
||||||
|
|
@ -207,22 +207,8 @@ async fn drain_swarm_events(
|
||||||
return;
|
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!(
|
tracing::info!(
|
||||||
%subject, %deploy_subject, %credential_subject,
|
%subject, %deploy_subject,
|
||||||
"swarm events: listening"
|
"swarm events: listening"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -254,13 +240,6 @@ async fn drain_swarm_events(
|
||||||
};
|
};
|
||||||
handle_deploy_request(&coord, &msg.payload).await;
|
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() => {
|
_ = shutdown.changed() => {
|
||||||
tracing::info!("swarm events: shutdown signal received");
|
tracing::info!("swarm events: shutdown signal received");
|
||||||
return;
|
return;
|
||||||
|
|
@ -274,39 +253,6 @@ async fn drain_swarm_events(
|
||||||
///
|
///
|
||||||
/// A payload that will not decode is worth a `warn`: the controller and this
|
/// 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.
|
/// 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(
|
async fn handle_deploy_request(
|
||||||
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
|
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
|
||||||
payload: &[u8],
|
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 agent_sockets;
|
||||||
pub mod auto_update;
|
pub mod auto_update;
|
||||||
pub mod crash_watch;
|
pub mod crash_watch;
|
||||||
pub mod credential;
|
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
pub mod mcp_sockets;
|
pub mod mcp_sockets;
|
||||||
pub mod scheduled_prompts_worker;
|
pub mod scheduled_prompts_worker;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
//!
|
//!
|
||||||
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
|
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
|
||||||
//! matrix-sdk `Client` each, with its own session/store dir + sync
|
//! matrix-sdk `Client` each, with its own session/store dir + sync
|
||||||
//! loop). Accounts come from the `HIVE_MATRIX_ACCOUNTS` env var (JSON,
|
//! loop). Accounts come from two places: the `HIVE_MATRIX_ACCOUNTS` env var
|
||||||
//! written by the nix harness module from
|
//! (JSON, written by the nix harness module from
|
||||||
//! `services.hyperhive.agent.matrixAccounts`), which declares the
|
//! `services.hyperhive.agent.matrixAccounts`), which declares the
|
||||||
//! **hive-internal account** (named `main`) as an ordinary entry
|
//! **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
|
//! `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
|
//! 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
|
//! `Registry::resolve` / `pick_name`) — the agent can't tell more than one
|
||||||
//! account exists, so it must choose explicitly.
|
//! account exists, so it must choose explicitly.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -30,9 +32,9 @@ use anyhow::Context as _;
|
||||||
use matrix_sdk::Client;
|
use matrix_sdk::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
|
||||||
/// same homeserver need not repeat it.
|
/// same homeserver need not repeat it.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
@ -40,9 +42,10 @@ pub struct AccountCfg {
|
||||||
/// Logical name the agent uses to address this account
|
/// Logical name the agent uses to address this account
|
||||||
/// (`account` arg on the MCP tools). Unique within the daemon.
|
/// (`account` arg on the MCP tools). Unique within the daemon.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Path to the bearer-token file for this account (hive-c0re
|
/// The declared bearer-token file, read when the store has no token for
|
||||||
/// writes it; the daemon reads it).
|
/// this account. `None` for an account the store links, whose token only
|
||||||
pub token_file: PathBuf,
|
/// the store holds.
|
||||||
|
pub token_file: Option<PathBuf>,
|
||||||
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
|
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
|
||||||
pub state_dir: PathBuf,
|
pub state_dir: PathBuf,
|
||||||
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
|
/// 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
|
/// The effective homeserver URL — this account's own, else the
|
||||||
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
|
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
|
||||||
///
|
///
|
||||||
/// `None` is a real answer, not a failure: the account is skipped, the
|
/// `None` is a real answer, not a failure: the account is skipped.
|
||||||
/// same way `discover_token_accounts_in` already skips a discovered
|
|
||||||
/// token whose homeserver sidecar is missing.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn homeserver(&self) -> Option<String> {
|
pub fn homeserver(&self) -> Option<String> {
|
||||||
self.homeserver.clone().or_else(paths::homeserver_url)
|
self.homeserver.clone().or_else(paths::homeserver_url)
|
||||||
|
|
@ -67,10 +68,9 @@ impl AccountCfg {
|
||||||
/// resolves to when it omits `account`.
|
/// resolves to when it omits `account`.
|
||||||
const HIVE_ACCOUNT: &str = "main";
|
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)
|
/// with the hive-internal `main` account hoisted to index 0 (= primary)
|
||||||
/// or synthesized there when the declaration doesn't carry one, plus any
|
/// or synthesized there when the declaration doesn't carry one.
|
||||||
/// dashboard-provisioned accounts discovered on disk.
|
|
||||||
///
|
///
|
||||||
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
|
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
|
||||||
/// single-account agent is unchanged.
|
/// 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}"))?,
|
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?,
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
let mut accounts = ensure_hive_account(declared)?;
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the hive-internal `main` account at index 0 of `declared`, then
|
/// 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,
|
0,
|
||||||
AccountCfg {
|
AccountCfg {
|
||||||
name: HIVE_ACCOUNT.to_owned(),
|
name: HIVE_ACCOUNT.to_owned(),
|
||||||
token_file: paths::token_file(),
|
token_file: Some(paths::token_file()),
|
||||||
state_dir: paths::matrix_state_dir(),
|
state_dir: paths::matrix_state_dir(),
|
||||||
homeserver: None,
|
homeserver: None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for a in &declared {
|
for a in &declared {
|
||||||
if !seen.insert(a.name.as_str()) {
|
if !seen.insert(a.name.as_str()) {
|
||||||
anyhow::bail!("duplicate matrix account name {:?}", a.name);
|
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)
|
Ok(declared)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan the agent state dir for extra matrix accounts provisioned via the
|
/// The accounts the store links to this agent beyond the declared ones, and
|
||||||
/// dashboard login form: each is a `matrix-token-<name>` file plus a
|
/// the linked names that could not be brought up.
|
||||||
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
|
#[derive(Debug, Default)]
|
||||||
/// [`AccountCfg`] per discovered account that has BOTH files — a token
|
pub struct Discovery {
|
||||||
/// without a sidecar is skipped, because the homeserver is then unknown and
|
/// Accounts to bring up, in the order the store lists them.
|
||||||
/// defaulting to the hive homeserver would be wrong for an external account.
|
pub accounts: Vec<AccountCfg>,
|
||||||
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
|
/// Listed names the store holds no credential for.
|
||||||
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
|
pub missing: Vec<String>,
|
||||||
/// authoritative.
|
/// Linked accounts stored without a homeserver. Defaulting to the hive's
|
||||||
///
|
/// would be wrong for an external account, so they are skipped.
|
||||||
/// `configured` is the set of names already declared in
|
pub no_homeserver: Vec<String>,
|
||||||
/// `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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of [`discover_token_accounts`] with the state dir injected, so the
|
/// Ask the store which accounts it links to this agent, as [`AccountCfg`]s
|
||||||
/// scan (and the configured-name skip) is unit-testable against a tempdir.
|
/// for every one `declared` does not already name.
|
||||||
fn discover_token_accounts_in(
|
///
|
||||||
state_dir: &Path,
|
/// Empty when the harness names no agent or the container was given no store.
|
||||||
configured: &std::collections::HashSet<String>,
|
///
|
||||||
) -> Vec<AccountCfg> {
|
/// # Errors
|
||||||
let Ok(rd) = std::fs::read_dir(state_dir) else {
|
/// The store refusing or failing a read; see [`credential::linked_accounts`].
|
||||||
return Vec::new();
|
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();
|
let Some(linked) = credential::linked_accounts(&agent).await? else {
|
||||||
for entry in rd.flatten() {
|
return Ok(Discovery::default());
|
||||||
let fname = entry.file_name();
|
};
|
||||||
let Some(fname) = fname.to_str() else {
|
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
|
||||||
continue;
|
let mut out = linked_cfgs(&state_root(), &declared, linked.found);
|
||||||
};
|
out.missing = linked.missing;
|
||||||
// `matrix-token` (no suffix) is the hive account, handled separately;
|
Ok(out)
|
||||||
// only `matrix-token-<name>` files are extra accounts.
|
}
|
||||||
let Some(name) = fname.strip_prefix("matrix-token-") else {
|
|
||||||
continue;
|
/// The pure half of [`discover_linked`], with the state dir injected so it is
|
||||||
};
|
/// testable without a store.
|
||||||
if name.is_empty() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
// Already declared in config: it's brought up from `HIVE_MATRIX_ACCOUNTS`,
|
let Some(homeserver) = l.homeserver.filter(|h| !h.is_empty()) else {
|
||||||
// not discovery, and its on-disk token needs no sidecar. Skip silently so
|
out.no_homeserver.push(l.name);
|
||||||
// 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)"
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
out.push(AccountCfg {
|
out.accounts.push(AccountCfg {
|
||||||
name: name.to_owned(),
|
state_dir: state_root.join(format!("matrix-sdk-state-{}", l.name)),
|
||||||
token_file: entry.path(),
|
name: l.name,
|
||||||
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
|
token_file: None,
|
||||||
homeserver: Some(homeserver),
|
homeserver: Some(homeserver),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
|
/// This agent's state dir, the parent of the `main` token file.
|
||||||
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
|
fn state_root() -> PathBuf {
|
||||||
/// absent / empty.
|
paths::token_file()
|
||||||
fn read_account_homeserver(path: &Path) -> Option<String> {
|
.parent()
|
||||||
let raw = std::fs::read_to_string(path).ok()?;
|
.map(Path::to_path_buf)
|
||||||
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
.unwrap_or_default()
|
||||||
json.get("homeserver")?
|
}
|
||||||
.as_str()
|
|
||||||
.filter(|s| !s.is_empty())
|
/// Remove token files a hive delivered into this agent's state dir: each
|
||||||
.map(ToOwned::to_owned)
|
/// `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`]
|
/// 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
|
/// file. Idempotent — skips the rewrite when the on-disk content already
|
||||||
/// matches, keeping the mtime stable. Used for the boot-time publish.
|
/// matches, keeping the mtime stable. Used for the boot-time publish.
|
||||||
///
|
///
|
||||||
/// The daemon rebuilds this file fresh on every boot (and is restarted
|
/// The daemon rebuilds this file fresh on every boot, and restarts itself
|
||||||
/// by the `matrix-token*` path-watcher when a new account is
|
/// when the store's linked accounts change, so the file lists exactly the
|
||||||
/// provisioned), so the file lists exactly the accounts that restored at
|
/// accounts that restored at the last start. Real-time liveness is conveyed by the file mtime,
|
||||||
/// the last start. Real-time liveness is conveyed by the file mtime,
|
|
||||||
/// which the daemon advances on a timer via
|
/// which the daemon advances on a timer via
|
||||||
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
|
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
|
||||||
/// down, so a reader can treat an old snapshot as stale.
|
/// down, so a reader can treat an old snapshot as stale.
|
||||||
|
|
@ -454,19 +472,21 @@ fn write_accounts_snapshot_inner(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AccountCfg, AccountStatus, discover_token_accounts_in, ensure_hive_account,
|
AccountCfg, AccountStatus, ensure_hive_account, heartbeat_accounts_snapshot, linked_cfgs,
|
||||||
heartbeat_accounts_snapshot, pick_name, write_accounts_snapshot,
|
pick_name, remove_delivered_files_in, write_accounts_snapshot,
|
||||||
};
|
};
|
||||||
|
use crate::credential::Linked;
|
||||||
|
|
||||||
/// A declared account, as the nix module would serialize it.
|
/// A declared account, as the nix module would serialize it.
|
||||||
fn cfg(name: &str) -> AccountCfg {
|
fn cfg(name: &str) -> AccountCfg {
|
||||||
AccountCfg {
|
AccountCfg {
|
||||||
name: name.to_owned(),
|
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}")),
|
state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")),
|
||||||
homeserver: Some(format!("https://{name}.example")),
|
homeserver: Some(format!("https://{name}.example")),
|
||||||
}
|
}
|
||||||
|
|
@ -482,7 +502,7 @@ mod tests {
|
||||||
assert_eq!(out[0].name, "main");
|
assert_eq!(out[0].name, "main");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out[0].token_file,
|
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"));
|
assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example"));
|
||||||
}
|
}
|
||||||
|
|
@ -521,9 +541,43 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
"hh-acct-disc-{}-{}",
|
"hh-acct-clean-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -531,33 +585,35 @@ mod tests {
|
||||||
.as_nanos()
|
.as_nanos()
|
||||||
));
|
));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let sidecar = r#"{"homeserver":"https://x.example"}"#;
|
||||||
// A statically-configured account: token on disk, NO sidecar.
|
// 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();
|
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
|
||||||
// An orphan dashboard token: no sidecar, not configured.
|
std::fs::write(dir.join("matrix-account-catgirl.json"), sidecar).unwrap();
|
||||||
std::fs::write(dir.join("matrix-token-orphan"), "tok").unwrap();
|
// An operator's by-hand token has no sidecar, and the hive account's own
|
||||||
// A fully dashboard-provisioned account: token + sidecar.
|
// token is not an extra: both stay.
|
||||||
std::fs::write(dir.join("matrix-token-good"), "tok").unwrap();
|
std::fs::write(dir.join("matrix-token-byhand"), "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-token"), "tok").unwrap();
|
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
|
||||||
|
|
||||||
let configured: HashSet<String> = ["main", "catgirl"]
|
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
|
||||||
.iter()
|
remove_delivered_files_in(&dir, &declared);
|
||||||
.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();
|
|
||||||
|
|
||||||
// catgirl (configured) + orphan (no sidecar) skipped; only `good` discovered.
|
let mut left: Vec<String> = std::fs::read_dir(&dir)
|
||||||
assert_eq!(found, vec!["good".to_owned()]);
|
.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();
|
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
|
//! 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
|
//! 🩸 **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
|
//! 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
|
//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the
|
||||||
//! path of the value at all.
|
//! path of the value at all.
|
||||||
//!
|
//!
|
||||||
//! The file arm is the hive-side delivery that still runs beside this one for
|
//! Which accounts exist is read the same way: [`linked_accounts`] lists this
|
||||||
//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a
|
//! agent's `matrix/` directory, which its policy grants `list` on
|
||||||
//! hive minted before the swarm did. That is what this replaces, not something
|
//! (`swarm_secret_client::policy::render_agent`).
|
||||||
//! it depends on, and it is the arm that goes when the hive-side loop does.
|
//!
|
||||||
|
//! 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};
|
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.
|
/// the whole point of this module is that the thing beside it never is.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Origin {
|
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),
|
File(PathBuf),
|
||||||
/// A path in the swarm secret store, read by this agent as itself.
|
/// A path in the swarm secret store, read by this agent as itself.
|
||||||
Store(String),
|
Store(String),
|
||||||
|
|
@ -102,34 +104,21 @@ impl Token {
|
||||||
/// Read this agent's credential for `account` out of the swarm secret
|
/// Read this agent's credential for `account` out of the swarm secret
|
||||||
/// store, under this agent's own certificate.
|
/// store, under this agent's own certificate.
|
||||||
///
|
///
|
||||||
/// `Ok(None)` means this deployment has no store: the agent's hive was
|
/// `Ok(None)` means this container was given no store (no `BAO_ADDR`).
|
||||||
/// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no
|
|
||||||
/// identity check and there is nothing here to log in to. That is an absent
|
|
||||||
/// integration, not a failure — the caller falls back to the file the hive
|
|
||||||
/// delivered.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// A name that is not a single path segment, an environment naming an
|
/// 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
|
/// identity that cannot be read, a store that refuses the certificate or
|
||||||
/// the path, or a credential stored empty.
|
/// the path, or a credential stored empty.
|
||||||
pub async fn from_store(agent: &str, account: &str) -> Result<Option<Self>> {
|
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)
|
let path = matrix::account_path(agent, account)
|
||||||
.context("building this agent's credential path in the store")?;
|
.context("building this agent's credential path in the store")?;
|
||||||
let role = policy::agent_object_name(agent)
|
let Some(store) = connect(agent).await? else {
|
||||||
.context("building this agent's cert-auth role name")?;
|
return Ok(None);
|
||||||
|
};
|
||||||
let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT)
|
// The stored object also carries the account's homeserver, dropped
|
||||||
.await
|
// here: the account's URL is settled before this runs
|
||||||
.context("logging in to the swarm secret store as this agent")?;
|
// (`AccountCfg::homeserver`), from config or from [`linked_accounts`].
|
||||||
// The stored object also carries the account's homeserver, and it is
|
|
||||||
// deliberately dropped here: the account's URL is already settled by
|
|
||||||
// the time this runs (`AccountCfg::homeserver`), and taking it from the
|
|
||||||
// store instead would change which accounts come up at all — that is
|
|
||||||
// the discovery half of this move, which goes with the hive-side loop
|
|
||||||
// rather than with the read.
|
|
||||||
let credential: matrix::Credential = store
|
let credential: matrix::Credential = store
|
||||||
.read(&path)
|
.read(&path)
|
||||||
.await
|
.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
|
/// `Ok(None)` when the file is not there — the account has not been
|
||||||
/// provisioned yet, which the caller treats as "skip", not "fail".
|
/// 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.
|
/// 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
|
/// `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
|
/// [`ENV_AGENT`] looks like, and such an agent keeps working off its declared
|
||||||
/// hive delivers.
|
/// token files.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn agent_name() -> Option<String> {
|
pub fn agent_name() -> Option<String> {
|
||||||
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,17 @@
|
||||||
//! respawn every turn.
|
//! respawn every turn.
|
||||||
//!
|
//!
|
||||||
//! Lifecycle:
|
//! Lifecycle:
|
||||||
//! 1. Read the configured account list (`accounts::configured()` —
|
//! 1. Read the account list: `accounts::configured()` (declared) plus
|
||||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
//! `accounts::discover_linked` (linked in the swarm secret store).
|
||||||
//! 2. For each account: resolve its access token (`account_token` — the
|
//! 2. For each account: resolve its access token (`account_token` — the
|
||||||
//! swarm secret store first, read by this agent as itself, then the
|
//! swarm secret store first, read by this agent as itself, then a declared
|
||||||
//! file its hive delivered) → whoami probe → recover `user_id` + `device_id`
|
//! token file) → whoami probe → recover `user_id` + `device_id`
|
||||||
//! → restore matrix-sdk session (no login flow), install the
|
//! → restore matrix-sdk session (no login flow), install the
|
||||||
//! message-event handler, and spawn its own sync loop.
|
//! message-event handler, and spawn its own sync loop.
|
||||||
//! 3. Serve the MCP tools against an account→Client registry; each tool
|
//! 3. Serve the MCP tools against an account→Client registry; each tool call
|
||||||
//! call routes to the account named in its `account` arg (the
|
//! routes to its `account` arg (the primary account when omitted).
|
||||||
//! 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 →
|
//! Standalone-degraded boot: the PRIMARY account having no token →
|
||||||
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
|
//! 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
|
//! is removed and that one account is skipped so the daemon keeps serving
|
||||||
//! the primary and any other healthy account.
|
//! the primary and any other healthy account.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashSet};
|
||||||
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
@ -68,8 +71,18 @@ const ACCOUNTS_HEARTBEAT_SECS: u64 = 30;
|
||||||
/// case; the total wait is ~52s before we give up.
|
/// case; the total wait is ~52s before we give up.
|
||||||
const SECONDARY_RETRY_DELAYS_SECS: &[u64] = &[2, 5, 15, 30];
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<ExitCode> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
|
@ -83,7 +96,13 @@ async fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
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 multi = cfgs.len() > 1;
|
||||||
let primary = cfgs[0].name.clone();
|
let primary = cfgs[0].name.clone();
|
||||||
let mut registry = Registry::new(primary);
|
let mut registry = Registry::new(primary);
|
||||||
|
|
@ -108,7 +127,7 @@ async fn main() -> Result<()> {
|
||||||
"primary matrix account has no token yet; exiting cleanly \
|
"primary matrix account has no token yet; exiting cleanly \
|
||||||
(systemd restarts us once one exists)"
|
(systemd restarts us once one exists)"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(ExitCode::SUCCESS);
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
|
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
|
||||||
|
|
@ -130,7 +149,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
if registry.is_empty() {
|
if registry.is_empty() {
|
||||||
tracing::warn!("no matrix accounts restored; exiting cleanly");
|
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
|
// 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
|
Ok(drive(sync_loops, wait_for_linked_change(declared, booted, warned)).await)
|
||||||
// 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.
|
/// Drive all per-account sync loops concurrently on this task (they aren't
|
||||||
let (result, idx, _rest) = futures_util::future::select_all(sync_loops).await;
|
/// `Send`, so no `tokio::spawn`) until one returns or `changed` resolves.
|
||||||
match result {
|
/// matrix-sdk reconnects internally, so any loop returning is exceptional —
|
||||||
Ok(()) => tracing::warn!(
|
/// log it and exit so systemd restarts the whole daemon cleanly.
|
||||||
account_index = idx,
|
async fn drive(
|
||||||
"a matrix sync loop exited cleanly; restarting daemon"
|
sync_loops: Vec<SyncLoop>,
|
||||||
),
|
changed: impl std::future::Future<Output = ()>,
|
||||||
Err(e) => {
|
) -> ExitCode {
|
||||||
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
|
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
|
/// 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
|
/// mints it any more. An agent whose harness forwards no
|
||||||
/// [`credential::ENV_AGENT`] cannot name its own subtree, so it reads the file.
|
/// [`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 file is what remains when the store answers nothing: a `tokenFile` an
|
||||||
/// the hive-side delivery loop still writes, or a `main` token a hive minted
|
/// operator declared, or a `main` token a hive minted before the swarm did. An
|
||||||
/// before the swarm did. A store read that *fails* falls back too, and says
|
/// account the store links has no file. A store read that *fails* falls back
|
||||||
/// why.
|
/// too, and says why.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// As [`credential::Token::from_file`]: a token file that exists but cannot be
|
/// 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() {
|
if let Some(agent) = credential::agent_name() {
|
||||||
match Token::from_store(&agent, &cfg.name).await {
|
match Token::from_store(&agent, &cfg.name).await {
|
||||||
Ok(Some(token)) => return Ok(Some(token)),
|
Ok(Some(token)) => return Ok(Some(token)),
|
||||||
// No store in this deployment: nothing to report, the hive-side
|
// No store in this deployment: the declared file is the whole
|
||||||
// delivery is the whole mechanism here.
|
// mechanism here.
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
account = %cfg.name,
|
account = %cfg.name,
|
||||||
error = %format!("{e:#}"),
|
error = %format!("{e:#}"),
|
||||||
"could not read this account's credential from the store as this agent; \
|
"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
|
/// Restore one account's client (when its token exists), install its
|
||||||
|
|
|
||||||
|
|
@ -479,40 +479,11 @@ pub enum PrivRequest {
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Agent credential writes ---
|
// --- 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`.
|
/// Write `github-token` into `AGENT_STATE_ROOT/<agent_name>/state/github-token`.
|
||||||
///
|
///
|
||||||
/// The operator-supplied GitHub personal access token (PAT) for the
|
/// The operator-supplied GitHub personal access token (PAT) for the
|
||||||
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`). Same write
|
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`).
|
||||||
/// semantics as
|
/// hive-priv validates `agent_name`, creates the state dir
|
||||||
/// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir
|
|
||||||
/// if absent, writes the file 0600, and chowns it to the agent so the
|
/// 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
|
/// `gh` wrapper / git credential helper can read it. No account suffix
|
||||||
/// (single GitHub account per agent).
|
/// (single GitHub account per agent).
|
||||||
|
|
@ -528,15 +499,13 @@ pub enum PrivRequest {
|
||||||
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
|
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
|
||||||
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
|
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
|
||||||
/// the base URL survives without any host-side nix config — the whole
|
/// the base URL survives without any host-side nix config — the whole
|
||||||
/// account (label + URL + token) is operator-entered on the dashboard,
|
/// account (label + URL + token) is operator-entered on the dashboard.
|
||||||
/// same shape as `WriteAgentMatrixToken`'s homeserver sidecar.
|
|
||||||
///
|
///
|
||||||
/// `label` MUST be validated as a plain identifier (same rule as the
|
/// `label` MUST be validated as a plain identifier before it goes into
|
||||||
/// matrix `account` suffix) before it goes into the filename — a
|
/// the filename — a crafted label could otherwise traverse out of the
|
||||||
/// crafted label could otherwise traverse out of the state dir. Same
|
/// state dir. Same write semantics as `WriteAgentGithubToken` — validates
|
||||||
/// write semantics as `WriteAgentMatrixToken` — validates `agent_name`,
|
/// `agent_name`, creates the state dir if absent, writes both files 0600,
|
||||||
/// creates the state dir if absent, writes both files 0600, chowns to
|
/// chowns to the agent.
|
||||||
/// the agent.
|
|
||||||
WriteAgentExtraForgeAccount {
|
WriteAgentExtraForgeAccount {
|
||||||
/// Logical agent name (validated by `validate_agent_name`).
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
agent_name: String,
|
agent_name: String,
|
||||||
|
|
|
||||||
|
|
@ -408,13 +408,6 @@ async fn exec(
|
||||||
paused,
|
paused,
|
||||||
} => exec_set_agent_paused(agent_name, 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 {
|
PrivRequest::WriteAgentGithubToken {
|
||||||
ref agent_name,
|
ref agent_name,
|
||||||
ref token,
|
ref token,
|
||||||
|
|
@ -530,34 +523,6 @@ async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> {
|
||||||
run_forge_admin(args).await
|
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
|
/// `WriteAgentExtraForgeAccount`: writes the token, then a
|
||||||
/// `forge-<label>.json` sidecar carrying the base URL — there's no
|
/// `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
|
/// 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(())
|
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`,
|
/// 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.
|
/// unique per call so two concurrent writes of one file never share a temp.
|
||||||
///
|
///
|
||||||
/// The leading dot and the `.partial` suffix are load-bearing:
|
/// The leading dot and the `.partial` suffix are load-bearing: a reader
|
||||||
/// `nix/agent-modules/matrix.nix` starts the agent's matrix daemon on the glob
|
/// globbing on a published name's prefix, or systemd reading `*.conf` out of
|
||||||
/// `matrix-token*`, and systemd reads only `*.conf` out of `tmpfiles.d` and
|
/// `tmpfiles.d` and drop-in dirs, cannot pick up a half-written temp.
|
||||||
/// drop-in dirs, so none of them can pick up a half-written temp.
|
|
||||||
fn partial_name(filename: &str) -> String {
|
fn partial_name(filename: &str) -> String {
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
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()
|
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
|
/// Sidecar written alongside a dashboard-provisioned extra forge
|
||||||
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
|
/// 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
|
/// 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(())
|
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
|
/// Validate a bind-mount path: must be absolute, non-empty, and contain
|
||||||
/// no newlines, null bytes, double-quotes, or colons.
|
/// no newlines, null bytes, double-quotes, or colons.
|
||||||
///
|
///
|
||||||
|
|
@ -3393,10 +3306,10 @@ mod tests {
|
||||||
BindMount, BoundedRun, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile,
|
BindMount, BoundedRun, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile,
|
||||||
check_fd_agreement, clear_runner_credentials, contains_secret_shaped_run,
|
check_fd_agreement, clear_runner_credentials, contains_secret_shaped_run,
|
||||||
describe_forge_admin, ensure_plain_filename, ensure_socket_dir_in, git_overlay_flags,
|
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,
|
limits_dropin_body, open_dir, open_export_dest, partial_name, publish_file,
|
||||||
publish_file, redact_secret_line, remove_marker_in, run_bounded, single_output_path,
|
redact_secret_line, remove_marker_in, run_bounded, single_output_path, toplevel_attr,
|
||||||
toplevel_attr, validate_account_name, validate_credential_name, validate_snapshot_name,
|
validate_credential_name, validate_snapshot_name, write_agent_dir_file,
|
||||||
write_agent_dir_file, write_bridge_dns_marker_in,
|
write_bridge_dns_marker_in,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
@ -3780,39 +3693,6 @@ mod tests {
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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
|
/// 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
|
/// `systemd.path` unit globbing `matrix-token*` would fire on a temp that
|
||||||
/// merely suffixed the real name, on exactly the empty file the rename
|
/// merely suffixed the real name, on exactly the empty file the rename
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
userName = config.services.hyperhive.agent.user.name;
|
userName = config.services.hyperhive.agent.user.name;
|
||||||
# This agent's own state dir, where a hive used to write the `main`
|
# This agent's own state dir: `main`'s fallback token file (`matrix-token`)
|
||||||
# account's token (`matrix-token`, now the store's fallback) and the
|
# and the daemon's matrix-sdk stores. Shared by the `main` account entry below
|
||||||
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
|
# and the path-watcher glob at the bottom of this file.
|
||||||
# below and the path-watcher glob at the bottom of this file.
|
|
||||||
stateDir = "/agents/${userName}/state";
|
stateDir = "/agents/${userName}/state";
|
||||||
accounts = config.services.hyperhive.agent.matrixAccounts;
|
accounts = config.services.hyperhive.agent.matrixAccounts;
|
||||||
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
|
# 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.
|
# non-zero exit, which `on-failure` already restarts.
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 5;
|
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;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
}
|
}
|
||||||
|
|
@ -416,18 +420,16 @@ in
|
||||||
timerConfig.OnUnitInactiveSec = "5min";
|
timerConfig.OnUnitInactiveSec = "5min";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Re-fire the daemon when the matrix token appears (a token
|
# Re-fire the daemon when a matrix token file appears (a declared
|
||||||
# file: an extra account the hive delivers, or a `main` from before the
|
# `tokenFile`, or a `main` from before the swarm minted it). Without this
|
||||||
# swarm minted it). Without this
|
|
||||||
# the daemon would exit 0 silently on first boot and the MCP
|
# the daemon would exit 0 silently on first boot and the MCP
|
||||||
# would have no backend until next restart. See
|
# would have no backend until next restart. See
|
||||||
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
||||||
systemd.paths.hive-matrix-daemon = lib.mkIf matrixEnabled {
|
systemd.paths.hive-matrix-daemon = lib.mkIf matrixEnabled {
|
||||||
description = "trigger hive-matrix-daemon when a matrix token appears";
|
description = "trigger hive-matrix-daemon when a matrix token appears";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
# `matrix-token*` (not just `matrix-token`) so a secondary
|
# `matrix-token*` (not just `matrix-token`) so a declared secondary
|
||||||
# multi-account token (e.g. `matrix-token-ccc`) landing also
|
# token file (e.g. `matrix-token-ccc`) landing also re-fires the daemon.
|
||||||
# re-fires the daemon to pick up the freshly-provisioned account.
|
|
||||||
#
|
#
|
||||||
# ⚠️ This agent's own state dir, not a glob over `/agents/*/`. Every
|
# ⚠️ This agent's own state dir, not a glob over `/agents/*/`. Every
|
||||||
# agent's state dir is visible from inside every container, so a
|
# 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
|
# 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
|
# spellings explicitly rather than vaultrs's `VAULT_*` defaults — falling
|
||||||
# through to those would build a client with no identity and fail at the TLS
|
# through to those would build a client with no identity and fail at the TLS
|
||||||
# handshake, naming neither.
|
# handshake, naming neither.
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,17 @@
|
||||||
//!
|
//!
|
||||||
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
//! **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
|
//! 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
|
//! The agent end is `hive-matrix-daemon`, which lists the agent's accounts and
|
||||||
//! value under its own identity and writes it into the agent's state dir. The
|
//! reads each under the agent's own certificate. No hive is in the path.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
|
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
|
||||||
//! (default, back-compat with the original blind-store shape — the caller
|
//! (default, back-compat with the original blind-store shape — the caller
|
||||||
//! already has a bearer token) and `password` (this daemon performs
|
//! already has a bearer token) and `password` (this daemon performs
|
||||||
//! `m.login.password` against the caller-given homeserver itself and stores
|
//! `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
|
//! the resulting token; the password is never stored — only the derived token
|
||||||
//! hive either — only the derived token is). Done here so the browser never
|
//! is). Done here so the browser never
|
||||||
//! has to hold the password long enough to call an arbitrary homeserver
|
//! has to hold the password long enough to call an arbitrary homeserver
|
||||||
//! directly.
|
//! directly.
|
||||||
|
|
||||||
|
|
@ -33,7 +26,6 @@ use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swarm_queue_client::{CredentialNotice, credential_subject};
|
|
||||||
use swarm_secret_client::matrix;
|
use swarm_secret_client::matrix;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
|
@ -103,11 +95,8 @@ pub struct PutMatrixAccountRequest {
|
||||||
/// Password-mode only. Never logged and never stored — only the token
|
/// Password-mode only. Never logged and never stored — only the token
|
||||||
/// `m.login.password` returns is.
|
/// `m.login.password` returns is.
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
/// The account's homeserver, when it is not this swarm's own.
|
/// 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.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Optional in token mode (omitted means "resolve to the agent's own
|
/// 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
|
/// `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>,
|
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
|
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||||
/// value the agent will next read rather than adding a second account.
|
/// value the agent will next read rather than adding a second account.
|
||||||
|
|
@ -136,16 +125,15 @@ pub struct PutMatrixAccountResponse {
|
||||||
put,
|
put,
|
||||||
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
||||||
params(
|
params(
|
||||||
("hive" = String, Path, description = "hive whose agent receives the credential"),
|
("hive" = String, Path, description = "hive the agent runs on"),
|
||||||
("agent" = String, Path, description = "agent the credential is delivered to"),
|
("agent" = String, Path, description = "agent the credential belongs to"),
|
||||||
("account" = String, Path, description = "the external account this credential authenticates as"),
|
("account" = String, Path, description = "the external account this credential authenticates as"),
|
||||||
),
|
),
|
||||||
request_body = PutMatrixAccountRequest,
|
request_body = PutMatrixAccountRequest,
|
||||||
responses(
|
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 = 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 failed (problem+json)", body = String),
|
||||||
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
|
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
|
|
@ -154,15 +142,6 @@ pub async fn put_matrix_account(
|
||||||
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
||||||
Json(req): Json<PutMatrixAccountRequest>,
|
Json(req): Json<PutMatrixAccountRequest>,
|
||||||
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
|
) -> 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 hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||||
let agent = hive_types::Ident::parse(&agent)
|
let agent = hive_types::Ident::parse(&agent)
|
||||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
.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.
|
// touched, so a failed login leaves no partial state behind.
|
||||||
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
|
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| {
|
let store = crate::store::connect().await.map_err(|e| {
|
||||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
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())
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let notice = CredentialNotice {
|
tracing::info!(%hive, %agent, %account, "credential stored");
|
||||||
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");
|
|
||||||
Ok(Json(PutMatrixAccountResponse { user_id }))
|
Ok(Json(PutMatrixAccountResponse { user_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,32 +478,17 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the connected-but-not-yet-connected queue ───────────────────────
|
/// Bare-minimum `AppState` for a handler test: one hive, nothing
|
||||||
|
/// queue-backed, and an empty in-memory job graph the endpoint under test
|
||||||
/// A client that exists but has never connected — the `Pending` state
|
/// never touches.
|
||||||
/// `ensure_connected`'s own doc says a `Disconnected` check would miss.
|
fn state() -> super::super::AppState {
|
||||||
/// `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 {
|
|
||||||
super::super::AppState {
|
super::super::AppState {
|
||||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||||
name: "pr1ma".to_owned(),
|
name: "pr1ma".to_owned(),
|
||||||
domain: "pr1ma.example".to_owned(),
|
domain: "pr1ma.example".to_owned(),
|
||||||
}]),
|
}]),
|
||||||
links: std::sync::Arc::new(Vec::new()),
|
links: std::sync::Arc::new(Vec::new()),
|
||||||
status: Some(std::sync::Arc::new(status)),
|
status: None,
|
||||||
wanted: None,
|
wanted: None,
|
||||||
agent_status: None,
|
agent_status: None,
|
||||||
agent_icons: None,
|
agent_icons: None,
|
||||||
|
|
@ -586,35 +505,44 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The defect this whole PR exists to close: a queue that is
|
/// Refused before the store is reached: with `BAO_*` unset a store
|
||||||
/// configured but not connected must not let this handler reach the
|
/// connect would answer 500, so a 400 is the reserved-name check.
|
||||||
/// 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.
|
|
||||||
#[tokio::test]
|
#[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"] {
|
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
|
||||||
assert!(
|
assert!(
|
||||||
std::env::var(var).is_err(),
|
std::env::var(var).is_err(),
|
||||||
"{var} must be unset for this test to prove anything"
|
"{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(
|
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((
|
axum::extract::Path((
|
||||||
"pr1ma".to_owned(),
|
"pr1ma".to_owned(),
|
||||||
"atlas".to_owned(),
|
"atlas".to_owned(),
|
||||||
|
|
@ -630,48 +558,10 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await;
|
.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!(
|
assert_eq!(
|
||||||
problem.status,
|
problem.status,
|
||||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
|
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
"{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),
|
|
||||||
"{problem:?}"
|
"{problem:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,17 +498,6 @@ impl Policy {
|
||||||
// admission). Same failure mode as the knowledge event above: a
|
// admission). Same failure mode as the knowledge event above: a
|
||||||
// refused publish reaches the client as a timeout.
|
// refused publish reaches the client as a timeout.
|
||||||
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
|
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
|
// The wanted-state buckets — one per hive, all created and written
|
||||||
// by this single client.
|
// 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]
|
#[test]
|
||||||
fn a_hive_may_not_publish_a_deploy_event_to_anyone_including_itself() {
|
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
|
// 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.
|
/// cannot drift into naming different families.
|
||||||
const DEPLOY_SUBJECT_PREFIX: &str = "$SWARM.deploy";
|
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.
|
/// What a [`deploy_subject`] message carries.
|
||||||
///
|
///
|
||||||
/// Only the agent: the subject already names the hive, and repeating it here
|
/// 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.
|
/// Write `value` at `path`, creating a new version.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,18 @@ use crate::{
|
||||||
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
||||||
/// another agent's secret.
|
/// another agent's secret.
|
||||||
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
||||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
|
||||||
checked_segment("account", account)?;
|
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`
|
/// 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.
|
/// 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
|
/// The homeserver rides with the token because the agent's daemon learns both
|
||||||
/// triggers a delivery, because a notice is not persistence — re-delivering a
|
/// from this one object: an account it finds by listing [`accounts_dir`] has
|
||||||
/// credential has to reconstruct it, and the store is the only thing that keeps
|
/// no other place its homeserver is written down.
|
||||||
/// it.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Credential {
|
pub struct Credential {
|
||||||
/// `glue-matrix-bao-token.nix` reads the store with
|
/// `glue-matrix-bao-token.nix` reads the store with
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue