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
|
|
@ -408,13 +408,6 @@ async fn exec(
|
|||
paused,
|
||||
} => exec_set_agent_paused(agent_name, paused),
|
||||
|
||||
PrivRequest::WriteAgentMatrixToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
ref account,
|
||||
ref homeserver,
|
||||
} => write_matrix_token(agent_name, token, account.as_deref(), homeserver.as_deref()),
|
||||
|
||||
PrivRequest::WriteAgentGithubToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
|
|
@ -530,34 +523,6 @@ async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> {
|
|||
run_forge_admin(args).await
|
||||
}
|
||||
|
||||
/// `WriteAgentMatrixToken`: `account = None` writes the hive account's
|
||||
/// `matrix-token`; `Some(a)` writes `matrix-token-<a>`. The account suffix
|
||||
/// MUST be validated as a plain identifier (no `/`, `.`, `..`) before it goes
|
||||
/// into the filename, or a crafted account could traverse out of the state
|
||||
/// dir — `write_agent_state_file` trusts its `filename` argument. When both
|
||||
/// `account` and `homeserver` are `Some`, also persists a
|
||||
/// `matrix-account-<a>.json` sidecar so the daemon can auto-discover the
|
||||
/// extra account without a static `matrixAccounts` config entry.
|
||||
fn write_matrix_token(
|
||||
agent_name: &str,
|
||||
token: &str,
|
||||
account: Option<&str>,
|
||||
homeserver: Option<&str>,
|
||||
) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
if let Some(a) = account {
|
||||
validate_account_name(a)?;
|
||||
}
|
||||
let filename = matrix_token_filename(account);
|
||||
let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?;
|
||||
if let (Some(a), Some(hs)) = (account, homeserver) {
|
||||
let meta = serde_json::to_string(&MatrixAccountSidecar { homeserver: hs })
|
||||
.context("serialize matrix account sidecar")?;
|
||||
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `WriteAgentExtraForgeAccount`: writes the token, then a
|
||||
/// `forge-<label>.json` sidecar carrying the base URL — there's no
|
||||
/// host-side nix config for extra forges, so this is the only place it's
|
||||
|
|
@ -1405,27 +1370,12 @@ fn ensure_plain_filename(who: &str, filename: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Basename an agent's matrix token is written under — `matrix-token` for the
|
||||
/// hive's own account, `matrix-token-<account>` for an extra one.
|
||||
///
|
||||
/// Half of an agreement whose other half is a `systemd.paths` glob in
|
||||
/// `nix/agent-modules/matrix.nix`, which no test here can reach: a name that
|
||||
/// stopped matching `matrix-token*` would land a credential the agent's daemon
|
||||
/// never wakes for — no error, just a token that silently never arrives.
|
||||
fn matrix_token_filename(account: Option<&str>) -> String {
|
||||
match account {
|
||||
None => "matrix-token".to_owned(),
|
||||
Some(a) => format!("matrix-token-{a}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the content is written under before being renamed onto `filename`,
|
||||
/// unique per call so two concurrent writes of one file never share a temp.
|
||||
///
|
||||
/// The leading dot and the `.partial` suffix are load-bearing:
|
||||
/// `nix/agent-modules/matrix.nix` starts the agent's matrix daemon on the glob
|
||||
/// `matrix-token*`, and systemd reads only `*.conf` out of `tmpfiles.d` and
|
||||
/// drop-in dirs, so none of them can pick up a half-written temp.
|
||||
/// The leading dot and the `.partial` suffix are load-bearing: a reader
|
||||
/// globbing on a published name's prefix, or systemd reading `*.conf` out of
|
||||
/// `tmpfiles.d` and drop-in dirs, cannot pick up a half-written temp.
|
||||
fn partial_name(filename: &str) -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -1581,20 +1531,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
|
|||
staged.publish()
|
||||
}
|
||||
|
||||
/// Sidecar written alongside an extra matrix account's token
|
||||
/// (`matrix-account-<name>.json`) so `hive-matrix-mcp` can auto-discover
|
||||
/// the account's homeserver without a static `matrixAccounts` config
|
||||
/// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s
|
||||
/// `read_account_homeserver` (deliberately reads via a bare
|
||||
/// `serde_json::Value` rather than this shape — that side treats a
|
||||
/// malformed/missing sidecar as "skip this account" rather than an
|
||||
/// error, so it stays loosely typed; this side is the one place the
|
||||
/// file is written, so it gets the precise shape).
|
||||
#[derive(Serialize)]
|
||||
struct MatrixAccountSidecar<'a> {
|
||||
homeserver: &'a str,
|
||||
}
|
||||
|
||||
/// Sidecar written alongside a dashboard-provisioned extra forge
|
||||
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
|
||||
/// the account's base URL. Read side: `hive-forge/src/client.rs`'s own
|
||||
|
|
@ -3051,29 +2987,6 @@ fn validate_name_chars(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a matrix account name, which is a wider charset than
|
||||
/// [`validate_name_chars`] allows on purpose.
|
||||
///
|
||||
/// An agent name is an `Ident` and lowercase by design. An account name is an
|
||||
/// attribute name in `services.hyperhive.agent.matrixAccounts`, typed `attrsOf` with no
|
||||
/// charset constraint, so `Ops_Relay9` is a key an operator may already have
|
||||
/// written. `swarm_secret_client::path::checked_segment` accepts exactly this
|
||||
/// set for the same name in the secret store — the two must agree, or a
|
||||
/// credential reads out of the store and then fails to land on disk.
|
||||
///
|
||||
/// Still a single plain component: no `/`, no `.`, no whitespace, so it cannot
|
||||
/// climb out of the agent's state dir or name the dir itself.
|
||||
fn validate_account_name(name: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
bail!("invalid account name {name:?}: must be non-empty [A-Za-z0-9_-]");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a bind-mount path: must be absolute, non-empty, and contain
|
||||
/// no newlines, null bytes, double-quotes, or colons.
|
||||
///
|
||||
|
|
@ -3393,10 +3306,10 @@ mod tests {
|
|||
BindMount, BoundedRun, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, StagedFile,
|
||||
check_fd_agreement, clear_runner_credentials, contains_secret_shaped_run,
|
||||
describe_forge_admin, ensure_plain_filename, ensure_socket_dir_in, git_overlay_flags,
|
||||
limits_dropin_body, matrix_token_filename, open_dir, open_export_dest, partial_name,
|
||||
publish_file, redact_secret_line, remove_marker_in, run_bounded, single_output_path,
|
||||
toplevel_attr, validate_account_name, validate_credential_name, validate_snapshot_name,
|
||||
write_agent_dir_file, write_bridge_dns_marker_in,
|
||||
limits_dropin_body, open_dir, open_export_dest, partial_name, publish_file,
|
||||
redact_secret_line, remove_marker_in, run_bounded, single_output_path, toplevel_attr,
|
||||
validate_credential_name, validate_snapshot_name, write_agent_dir_file,
|
||||
write_bridge_dns_marker_in,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
|
@ -3780,39 +3693,6 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Ported from `hive-c0re`'s `credential.rs`, which used to build this path
|
||||
/// itself. The controls are the load-bearing half: an account name is an
|
||||
/// attrset key in `services.hyperhive.agent.matrixAccounts`, so uppercase and underscore
|
||||
/// are names an operator can already have written, and the secret store
|
||||
/// accepts exactly this set for the same name. A validator narrower than
|
||||
/// the store's reads a credential out and then refuses to land it.
|
||||
#[test]
|
||||
fn an_account_name_is_checked_against_the_same_charset_the_store_uses() {
|
||||
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
|
||||
assert!(
|
||||
validate_account_name(bad).is_err(),
|
||||
"account {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
assert!(validate_account_name("ops-relay").is_ok());
|
||||
assert!(validate_account_name("Ops_Relay9").is_ok());
|
||||
}
|
||||
|
||||
/// Pinned here because here is where the name is decided. `hive-c0re` used
|
||||
/// to assert this against a path helper of its own, which stopped deciding
|
||||
/// anything the moment credential delivery started routing through this
|
||||
/// process — a test that would have kept passing while the real filename
|
||||
/// drifted.
|
||||
#[test]
|
||||
fn a_matrix_token_is_named_for_the_glob_the_daemon_watches() {
|
||||
assert_eq!(matrix_token_filename(None), "matrix-token");
|
||||
assert_eq!(matrix_token_filename(Some("ccc")), "matrix-token-ccc");
|
||||
// And the temp the publish goes through must not match that same glob,
|
||||
// which is only checkable now that both names are built in one place.
|
||||
let tmp = partial_name(&matrix_token_filename(Some("ccc")));
|
||||
assert!(!tmp.starts_with("matrix-token"), "got {tmp}");
|
||||
}
|
||||
|
||||
/// The temp name is the whole reason the rename is safe to watch: a
|
||||
/// `systemd.path` unit globbing `matrix-token*` would fire on a temp that
|
||||
/// merely suffixed the real name, on exactly the empty file the rename
|
||||
|
|
|
|||
Loading…
Reference in a new issue