Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-01 17:41:20 +02:00
commit 97fb76ce99
22 changed files with 553 additions and 813 deletions

View file

@ -94,9 +94,7 @@ enum Skipped {
async fn collect(agent: &str, dir: &Path) -> Result<(), Skipped> {
// The hive's name is the cert-auth role it logs in as: `glue-bao-tls.nix`
// mints this host's client certificate with the hive name as its CN and a
// bao cert role matches on CN, so the two share a name by construction —
// the same reasoning `swarm_status::handle_credential_notice` records for
// the other read this hive does on an agent's behalf.
// bao cert role matches on CN, so the two share a name by construction.
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
return Err(Skipped::NotConfigured(
"HYPERHIVE_HIVE_NAME is unset, so this hive has no role to log in as",

View file

@ -217,7 +217,7 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
/// store identity.
///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in
/// the same role string `lifecycle::agent_identity` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. That name is also the path's own hive segment, which is
/// what makes this read reach **this** hive's token and 403 on any other's:

View file

@ -369,32 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?)
}
/// Write a Matrix access token for `agent_name` via hive-priv (running as
/// root). `account: None` writes the hive-internal
/// `<state>/matrix-token`; `account: Some(name)` writes
/// `<state>/matrix-token-<name>` for an extra (external) account. The file
/// is written 0600 and chowned to the agent user so it is readable from
/// inside the agent container. hive-priv validates the account suffix.
///
/// `homeserver: Some(url)` (only meaningful with `account: Some`) also
/// writes the sidecar `<state>/matrix-account-<name>.json` so the daemon
/// can auto-discover the extra account without a `matrixAccounts` config
/// declaration (see issue tracker "external matrix account auto-discovery").
pub async fn write_agent_matrix_token(
agent_name: &str,
token: &str,
account: Option<&str>,
homeserver: Option<&str>,
) -> Result<()> {
ok(call(&PrivRequest::WriteAgentMatrixToken {
agent_name: agent_name.to_owned(),
token: token.to_owned(),
account: account.map(ToOwned::to_owned),
homeserver: homeserver.map(ToOwned::to_owned),
})
.await?)
}
/// Write a GitHub personal access token (PAT) for `agent_name` via hive-priv
/// (running as root). Writes `<state>/github-token` 0600, chowned to the agent
/// user so the `gh` wrapper / git credential helper can read it from inside the

View file

@ -207,22 +207,8 @@ async fn drain_swarm_events(
return;
}
};
// Also this hive's own, and for a second reason on top of the deploy
// subject's: the payload names an agent in *this* hive's state dir, so a
// notice for another hive is not merely noise, it is unactionable here.
let credential_subject = swarm_queue_client::credential_subject(&hive);
let mut credential_sub = match client.subscribe(credential_subject.clone()).await {
Ok(sub) => sub,
Err(e) => {
tracing::warn!(
subject = %credential_subject, error = %e,
"swarm events: subscribe failed; this hive will not hear credential notices"
);
return;
}
};
tracing::info!(
%subject, %deploy_subject, %credential_subject,
%subject, %deploy_subject,
"swarm events: listening"
);
@ -254,13 +240,6 @@ async fn drain_swarm_events(
};
handle_deploy_request(&coord, &msg.payload).await;
}
msg = credential_sub.next() => {
let Some(msg) = msg else {
tracing::warn!(subject = %credential_subject, "swarm events: credential subscription closed");
return;
};
handle_credential_notice(&hive, &msg.payload).await;
}
_ = shutdown.changed() => {
tracing::info!("swarm events: shutdown signal received");
return;
@ -274,39 +253,6 @@ async fn drain_swarm_events(
///
/// A payload that will not decode is worth a `warn`: the controller and this
/// end share one type, so a decode failure means they disagree about it.
/// Deliver the credential a [`swarm_queue_client::CredentialNotice`] names.
///
/// `hive` doubles as the cert-auth role this hive logs into the store as:
/// `glue-bao-tls.nix` mints the client certificate with the hive name as its
/// CN, and a bao cert role matches on CN — so the two share a name by
/// construction rather than by convention.
///
/// ⚠️ Nothing here can log the secret, and that is structural rather than
/// careful: the notice carries only names, and `deliver` writes the value
/// without returning it.
async fn handle_credential_notice(hive: &str, payload: &[u8]) {
let notice: swarm_queue_client::CredentialNotice = match serde_json::from_slice(payload) {
Ok(notice) => notice,
Err(e) => {
tracing::warn!(error = %e, "swarm events: undecodable credential notice");
return;
}
};
if let Err(e) = crate::workers::credential::deliver(&notice, hive).await {
// Warn rather than retry: the controller republishes, and a hive that
// spun here would hold the queue task off its other two subjects.
tracing::warn!(
agent = %notice.agent, account = %notice.account, error = ?e,
"swarm events: credential delivery failed"
);
return;
}
tracing::info!(
agent = %notice.agent, account = %notice.account,
"swarm events: credential delivered"
);
}
async fn handle_deploy_request(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
payload: &[u8],

View file

@ -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(&notice.agent)
.map_err(|e| anyhow::anyhow!("agent name {:?} off the queue: {e}", notice.agent))?;
let secret_path = matrix::account_path(&notice.agent, &notice.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(&notice.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());
}
}

View file

@ -8,7 +8,6 @@
pub mod agent_sockets;
pub mod auto_update;
pub mod crash_watch;
pub mod credential;
pub mod knowledge;
pub mod mcp_sockets;
pub mod scheduled_prompts_worker;