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

@ -8,24 +8,17 @@
//!
//! **The external one** — [`put_matrix_account`] and everything under it — is
//! an account somewhere else that an operator hands us a credential for: put it
//! in the swarm's secret store, then tell that agent's hive it is there.
//! in the swarm's secret store.
//!
//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the
//! value under its own identity and writes it into the agent's state dir. The
//! notice carries only names, so the queue never holds the secret — see
//! [`swarm_queue_client::credential_subject`] for why that is a requirement
//! rather than a preference.
//!
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
//! that overtakes its own write reaches a hive that reads nothing, and the
//! hive deliberately does not retry.
//! The agent end is `hive-matrix-daemon`, which lists the agent's accounts and
//! reads each under the agent's own certificate. No hive is in the path.
//!
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
//! (default, back-compat with the original blind-store shape — the caller
//! already has a bearer token) and `password` (this daemon performs
//! `m.login.password` against the caller-given homeserver itself and stores
//! the resulting token; the password is never stored, and is not sent to the
//! hive either — only the derived token is). Done here so the browser never
//! the resulting token; the password is never stored — only the derived token
//! is). Done here so the browser never
//! has to hold the password long enough to call an arbitrary homeserver
//! directly.
@ -33,7 +26,6 @@ use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::matrix;
use utoipa::ToSchema;
@ -103,11 +95,8 @@ pub struct PutMatrixAccountRequest {
/// Password-mode only. Never logged and never stored — only the token
/// `m.login.password` returns is.
password: Option<String>,
/// The account's homeserver, when it is not this swarm's own.
///
/// Stored beside the token rather than sent on the notice: a notice is a
/// queue message, so a homeserver carried there would exist only in
/// flight, with nowhere to reconstruct it from on a re-delivery.
/// The account's homeserver, when it is not this swarm's own. Stored
/// beside the token, which is where the agent's daemon reads it.
///
/// Optional in token mode (omitted means "resolve to the agent's own
/// `services.hyperhive.agent.matrix.url` on the hive side" — this route never needs to
@ -128,7 +117,7 @@ pub struct PutMatrixAccountResponse {
user_id: Option<String>,
}
/// Store an agent's external matrix account credential and notify its hive.
/// Store an agent's external matrix account credential.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// value the agent will next read rather than adding a second account.
@ -136,16 +125,15 @@ pub struct PutMatrixAccountResponse {
put,
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
params(
("hive" = String, Path, description = "hive whose agent receives the credential"),
("agent" = String, Path, description = "agent the credential is delivered to"),
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the credential belongs to"),
("account" = String, Path, description = "the external account this credential authenticates as"),
),
request_body = PutMatrixAccountRequest,
responses(
(status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
(status = 200, description = "stored", body = PutMatrixAccountResponse),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up, or it is not connected (problem+json)", body = String),
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
),
tag = "agents"
)]
@ -154,15 +142,6 @@ pub async fn put_matrix_account(
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutMatrixAccountRequest>,
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
// The queue first, so a deployment that has none answers 503 whatever the
// caller spelled — and before the store is touched, so a request that
// could never be delivered does not leave a credential behind.
let Some(status) = state.status.as_ref() else {
return Err(error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"this deployment wired up no swarm queue, so there is no hive to notify",
));
};
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
@ -191,21 +170,6 @@ pub async fn put_matrix_account(
// touched, so a failed login leaves no partial state behind.
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
// A queue that is configured but not yet (or no longer) connected does
// not fail the `publish`/`flush` further down outright — it *hangs*
// them, per `swarm_queue_client::ensure_connected`'s own doc. Checked
// here, immediately before the store is touched, so a request that
// cannot be delivered never leaves a credential behind — the same
// ordering rule the module doc states for "store first, notify
// second", extended one step earlier.
let client = status.queue_client();
swarm_queue_client::ensure_connected(&client).map_err(|e| {
error_problem(
StatusCode::SERVICE_UNAVAILABLE,
&swarm_queue_client::chain(&e),
)
})?;
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
@ -225,37 +189,7 @@ pub async fn put_matrix_account(
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
let notice = CredentialNotice {
agent: agent.clone(),
account: account.clone(),
};
let payload = serde_json::to_vec(&notice).map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("encoding the credential notice failed: {e}"),
)
})?;
let subject = credential_subject(&hive);
client
.publish(subject.clone(), payload.into())
.await
.map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("publishing to {subject} failed: {e}"),
)
})?;
// Flushed for the reason `publish_deploy` flushes: `publish` hands the
// message to the connection's write buffer and returns, so without this
// the response can outrun the notice it reports as sent.
client.flush().await.map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("flushing the credential notice to {subject} failed: {e}"),
)
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
tracing::info!(%hive, %agent, %account, "credential stored");
Ok(Json(PutMatrixAccountResponse { user_id }))
}
@ -544,32 +478,17 @@ mod tests {
);
}
// ── the connected-but-not-yet-connected queue ───────────────────────
/// A client that exists but has never connected — the `Pending` state
/// `ensure_connected`'s own doc says a `Disconnected` check would miss.
/// `retry_on_initial_connect` is what makes `.connect()` return
/// immediately instead of blocking on a handshake that will never
/// succeed against a loopback port nothing listens on.
async fn disconnected_client() -> async_nats::Client {
async_nats::ConnectOptions::new()
.retry_on_initial_connect()
.connect("127.0.0.1:1")
.await
.expect("retry_on_initial_connect returns without waiting for a real connection")
}
/// Bare-minimum `AppState` for a handler test: one hive, no queue-backed
/// helpers beyond `status` (the field this handler actually reads), and
/// an empty in-memory job graph the endpoint under test never touches.
fn state_with_status(status: super::super::status::StatusReader) -> super::super::AppState {
/// Bare-minimum `AppState` for a handler test: one hive, nothing
/// queue-backed, and an empty in-memory job graph the endpoint under test
/// never touches.
fn state() -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: Some(std::sync::Arc::new(status)),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
@ -586,35 +505,44 @@ mod tests {
}
}
/// The defect this whole PR exists to close: a queue that is
/// configured but not connected must not let this handler reach the
/// store write at all.
///
/// `BAO_ADDR`/`BAO_CLIENT_CERT`/`BAO_CLIENT_KEY` are asserted unset
/// first — not incidental setup, but the control that makes the 503
/// meaningful. If `put_matrix_account` reached `crate::store::connect()`
/// with those unset, *that* call fails too, and would also answer with
/// a `problem+json` body (500, "connecting to the swarm secret store
/// failed"). A 503 here is therefore proof execution never got past
/// `ensure_connected`, not a coincidence of two paths landing on the
/// same status family.
/// Refused before the store is reached: with `BAO_*` unset a store
/// connect would answer 500, so a 400 is the reserved-name check.
#[tokio::test]
async fn a_disconnected_queue_answers_503_and_never_reaches_the_store() {
async fn a_reserved_account_name_is_refused_before_the_store() {
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
assert!(
std::env::var(var).is_err(),
"{var} must be unset for this test to prove anything"
);
}
let reader = super::super::status::StatusReader::new(
disconnected_client().await,
std::time::Duration::from_mins(1),
);
let state = state_with_status(reader);
let result = super::put_matrix_account(
axum::extract::State(state),
axum::extract::State(state()),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())),
axum::Json(PutMatrixAccountRequest {
mode: "token".to_owned(),
token: Some("t0k3n".to_owned()),
user_id: None,
password: None,
homeserver: None,
}),
)
.await;
let problem = result.expect_err("'main' is reserved");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{problem:?}"
);
}
/// The control for the test above: an ordinary name gets past every check
/// and fails at the store connect, so the 400 above is not what every call
/// answers.
#[tokio::test]
async fn an_ordinary_account_name_reaches_the_store() {
let result = super::put_matrix_account(
axum::extract::State(state()),
axum::extract::Path((
"pr1ma".to_owned(),
"atlas".to_owned(),
@ -630,48 +558,10 @@ mod tests {
)
.await;
let problem = result.expect_err("a disconnected queue must refuse, not hang or 500");
let problem = result.expect_err("no store is configured in a test");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
"{problem:?}"
);
}
/// The control for the test above: a client that starts in `Pending`
/// but genuinely never connects is exactly what `ensure_connected` is
/// specified to reject — proving the 503 above tracks the connection
/// state and is not simply what every call through this handler
/// returns. Same client shape, mode rejected before either queue or
/// store is touched, so it exercises a different early return
/// (`is_reserved_account`) and confirms the handler still validates
/// normally on a path that never reaches `ensure_connected`'s sibling
/// checks.
#[tokio::test]
async fn a_reserved_account_name_is_still_refused_before_any_queue_check_matters() {
let reader = super::super::status::StatusReader::new(
disconnected_client().await,
std::time::Duration::from_mins(1),
);
let state = state_with_status(reader);
let result = super::put_matrix_account(
axum::extract::State(state),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())),
axum::Json(PutMatrixAccountRequest {
mode: "token".to_owned(),
token: Some("t0k3n".to_owned()),
user_id: None,
password: None,
homeserver: None,
}),
)
.await;
let problem = result.expect_err("'main' is reserved regardless of queue state");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{problem:?}"
);
}