hyperhive/hive-matrix-mcp/src/main.rs
atlas 0e9b1c563d fix(#2860): no loopback default for the matrix homeserver
Third and last of #2860's agent-facing URL fallbacks. The operator's
ruling was "any special casing is done on the nix side - same binaries,
no hard coded fallback", so the default is deleted rather than replaced.

Every layer guessed the same wrong thing, and each guess was only ever
correct for a process sharing the host netns:

- nix/agent-modules/matrix.nix: matrixUrlDefault = localhost:8008, both
  as the option's default and as a sentinel the daemon unit compared
  against to decide whether to write HIVE_MATRIX_URL. Now nullOr str,
  default null, the guard is != null, and the doc says what forge.url's
  already says: null means "no matrix", not "guess one".
- nix/host-modules/hive-c0re/environment.nix: forwarded
  http://127.0.0.1:<port> when no gatewayHost was set. hive-c0re shares
  the host netns so it reads as harmless, but the value is handed to
  agents, which do not -- there it names the agent itself. Now forwarded
  only when there is a gateway vhost to name, matching the guard
  HIVE_MATRIX_PUBLIC_URL already uses twelve lines below.
- hive-matrix-mcp: paths::DEFAULT_HOMESERVER was the same address
  compiled in, so dropping the nix defaults alone would have left the
  daemon dialling loopback inside the agent's own netns -- the very bug,
  one layer down. homeserver_url() is now Option, and an account with no
  homeserver is skipped with a log, exactly as one with no token is.
  discover_token_accounts already refused to guess for the same reason.

Two comments taught the assumption back to the next reader ("shared host
netns means every agent container resolves localhost to the same
machine"); both now say which side of the netns boundary they describe.
MATRIX_HTTP keeps its value -- hive-c0re really does share the host
netns -- but no longer claims agents do.

Gated with nix eval against the extended agent-base config, as a pair:
with no url set the daemon unit carries no HIVE_MATRIX_URL, and with one
set it carries exactly that. Either check alone passes on a broken guard.
2026-08-03 20:34:36 +02:00

335 lines
14 KiB
Rust

//! `hive-matrix-daemon` binary — long-running matrix-sdk Client + sync
//! loop per matrix account. Bridges incoming room events to hyperhive
//! wake signals and serves its MCP tools directly over streamable-http
//! on `--http <addr>` — no stdio bridge, no separate bin claude has to
//! respawn every turn.
//!
//! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
//! → restore matrix-sdk session (no login flow), install the
//! message-event handler, and spawn its own sync loop.
//! 3. Serve the MCP tools against an account→Client registry; each tool
//! call routes to the account named in its `account` arg (the
//! primary account when omitted).
//!
//! Standalone-degraded boot: the PRIMARY account having no token file →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
//! provisions it. A SECONDARY account missing its token is skipped (the
//! daemon still serves the others).
//!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy
//! above — a rejected PRIMARY token drops the stale token + sdk state and
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
//! is removed and that one account is skipped so the daemon keeps serving
//! the primary and any other healthy account.
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use matrix_sdk::{Client, config::SyncSettings};
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
use hive_matrix_mcp::client::PermanentBringUpError;
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
#[derive(Parser)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
struct Cli {
/// Serve the MCP tools over streamable-http on this address (e.g.
/// `127.0.0.1:8792`). Bind loopback only.
#[arg(long)]
http: std::net::SocketAddr,
}
/// A per-account sync loop, boxed so loops for N accounts can be driven
/// concurrently on the main task. Deliberately NOT `Send`: matrix-sdk's
/// sync future isn't `Send`, so these run on the current task (via
/// `select_all`) rather than `tokio::spawn`/`JoinSet`.
type SyncLoop = std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>>>>;
/// How often the daemon rewrites the accounts snapshot to advance its
/// mtime, so the dashboard's `as_of` tracks daemon liveness. 30s keeps
/// the staleness window small while the rewrite cost (one tmp + rename of
/// a tiny file) is negligible.
const ACCOUNTS_HEARTBEAT_SECS: u64 = 30;
/// Retry delays (seconds) for transient secondary-account failures.
/// After the initial attempt we back off through these before giving up
/// and skipping the account for the rest of the daemon lifetime.
/// Transient = anything that is NOT a `PermanentBringUpError` (bad/expired
/// token). A down homeserver at DNS-not-ready boot time is the typical
/// case; the total wait is ~52s before we give up.
const SECONDARY_RETRY_DELAYS_SECS: &[u64] = &[2, 5, 15, 30];
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
let cfgs = accounts::configured().context("read matrix account config")?;
let multi = cfgs.len() > 1;
let primary = cfgs[0].name.clone();
let mut registry = Registry::new(primary);
let mut sync_loops: Vec<SyncLoop> = Vec::new();
for (idx, cfg) in cfgs.into_iter().enumerate() {
let is_primary = idx == 0;
// Account-tag the todos only in multi-account mode so single-
// account todo summaries stay byte-identical to the legacy format.
let tag = multi.then(|| cfg.name.clone());
match bring_up_account(&cfg, tag.clone(), is_primary).await {
Ok(Some((client, sync_loop))) => {
registry.insert(cfg.name, client);
sync_loops.push(sync_loop);
}
// No token yet: for the primary that means the daemon isn't
// useful — exit 0 like the legacy single-account path so the
// systemd path-watcher restarts us when the token appears.
Ok(None) if is_primary => {
tracing::warn!(
account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \
(systemd restarts us when hive-c0re provisions it)"
);
return Ok(());
}
Ok(None) => {
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
}
// The primary failing to restore is fatal (propagate so
// systemd retries on a transient blip — matches legacy
// behaviour); a secondary failing is retried with backoff
// before being skipped for this daemon lifetime.
Err(e) if is_primary => return Err(e.context("bring up primary matrix account")),
Err(e) => {
if let Some((client, sync_loop)) = bring_up_secondary_with_retry(&cfg, tag, e).await
{
registry.insert(cfg.name, client);
sync_loops.push(sync_loop);
}
}
}
}
if registry.is_empty() {
tracing::warn!("no matrix accounts restored; exiting cleanly");
return Ok(());
}
// Publish the live-account snapshot (BE-4) for the dashboard: the
// accounts that restored, with effective homeserver + user id. Lands
// in the host-visible state dir so hive-c0re reads it to show live
// up/down + backfill homeserver. Best-effort — a failed write must
// not stop the daemon from serving.
if let Err(e) = registry.write_snapshot(&paths::accounts_file()) {
tracing::warn!(error = %format!("{e:#}"), "failed to write matrix-accounts snapshot");
}
// Serve the MCP tools against the registry. Spawned before driving
// the sync loops so claude can reach the stable http URL as soon as
// the first turn fires.
let registry = Arc::new(registry);
// Heartbeat: periodically rewrite the accounts snapshot so its mtime
// advances while the daemon lives. The dashboard derives `as_of` from
// the file mtime, so a stalled mtime now means the daemon is down —
// which lets the dashboard dim accounts whose snapshot is stale rather
// than reporting the boot-time set forever (BE-4 follow-up).
let hb_registry = Arc::clone(&registry);
tokio::spawn(async move {
let mut tick =
tokio::time::interval(std::time::Duration::from_secs(ACCOUNTS_HEARTBEAT_SECS));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let path = paths::accounts_file();
loop {
tick.tick().await;
if let Err(e) = hb_registry.heartbeat_snapshot(&path) {
tracing::warn!(error = %format!("{e:#}"), "matrix-accounts heartbeat write failed");
}
}
});
let http_addr = cli.http;
let mcp_registry = Arc::clone(&registry);
tokio::spawn(async move {
if let Err(e) = mcp::serve_http(http_addr, mcp_registry).await {
tracing::error!(error = %e, "mcp http server exited");
}
});
// Drive all per-account sync loops concurrently on this task (they
// aren't `Send`, so no `tokio::spawn`). matrix-sdk reconnects
// internally, so any loop returning is exceptional — log it and exit
// so systemd restarts the whole daemon cleanly.
let (result, idx, _rest) = futures_util::future::select_all(sync_loops).await;
match result {
Ok(()) => tracing::warn!(
account_index = idx,
"a matrix sync loop exited cleanly; restarting daemon"
),
Err(e) => {
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
}
}
Ok(())
}
/// Try to bring up a secondary account, retrying with exponential backoff
/// on transient failures (anything that is NOT a `PermanentBringUpError`).
/// Returns `Some((client, sync_loop))` on success, or `None` to signal
/// that the account should be skipped for this daemon lifetime (permanent
/// failure, no token, or all retries exhausted).
async fn bring_up_secondary_with_retry(
cfg: &AccountCfg,
tag: Option<String>,
first_error: anyhow::Error,
) -> Option<(Client, SyncLoop)> {
// Permanent failure: the token was invalid/expired and has already been
// removed from disk. Retrying won't help — skip immediately.
if first_error
.downcast_ref::<PermanentBringUpError>()
.is_some()
{
tracing::error!(
account = %cfg.name,
error = %format!("{first_error:#}"),
"secondary matrix account: permanent failure; skipping"
);
return None;
}
// Transient failure (network/DNS/homeserver 5xx): retry with backoff.
tracing::warn!(
account = %cfg.name,
error = %format!("{first_error:#}"),
"secondary matrix account bring-up failed (transient); will retry"
);
for &delay in SECONDARY_RETRY_DELAYS_SECS {
tracing::info!(
account = %cfg.name,
delay_s = delay,
"retrying secondary account bring-up after backoff"
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
match bring_up_account(cfg, tag.clone(), false).await {
Ok(Some((client, sync_loop))) => {
tracing::info!(account = %cfg.name, "secondary matrix account recovered");
return Some((client, sync_loop));
}
Ok(None) => {
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
return None;
}
Err(re) if re.downcast_ref::<PermanentBringUpError>().is_some() => {
tracing::error!(
account = %cfg.name,
error = %format!("{re:#}"),
"secondary matrix account: permanent failure on retry; skipping"
);
return None;
}
Err(re) => {
tracing::warn!(
account = %cfg.name,
error = %format!("{re:#}"),
"secondary matrix account bring-up still failing (transient)"
);
}
}
}
tracing::error!(
account = %cfg.name,
retries = SECONDARY_RETRY_DELAYS_SECS.len(),
"secondary matrix account failed after all retries; skipping for this daemon lifetime"
);
None
}
/// Restore one account's client (when its token exists), install its
/// message handler, and build its sync loop. Returns
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
/// when its token file is absent (caller decides primary-vs-secondary
/// handling). The returned sync loop is driven by the caller.
async fn bring_up_account(
cfg: &AccountCfg,
tag: Option<String>,
is_primary: bool,
) -> Result<Option<(Client, SyncLoop)>> {
let Some(homeserver) = cfg.homeserver() else {
// No homeserver for this account: the hive has none to offer (no
// matrix vhost) or this agent's `hyperhive.matrix.url` is null. Same
// no-op as a missing token — an absent integration, not a guess at
// one.
tracing::info!(
account = %cfg.name,
"no homeserver configured (HIVE_MATRIX_URL unset); skipping account"
);
return Ok(None);
};
if !tokio::fs::try_exists(&cfg.token_file)
.await
.unwrap_or(false)
{
return Ok(None);
}
tracing::info!(
account = %cfg.name,
homeserver,
token_file = %cfg.token_file.display(),
state_dir = %cfg.state_dir.display(),
"bringing up matrix account"
);
let client =
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
.await
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
// Best-effort: sync the agent icon to this account's matrix avatar over
// the live (authenticated, correct-homeserver) Client. Replaces the old
// curl oneshot; failures are swallowed inside sync_avatar.
client::sync_avatar(&client, &cfg.state_dir, &cfg.name).await;
// Best-effort: bootstrap cross-signing so the account's device isn't
// flagged "unverified" in other users' clients. Idempotent + swallows
// failures (a UIAA-requiring homeserver) inside ensure_cross_signing.
client::ensure_cross_signing(&client, &cfg.name).await;
let sync_client = client.clone();
let cb_client = client.clone();
// Separate dedup sets: one tracks invites already pushed as todos, the
// other unread-message rooms. Both are pruned to their current state
// each sweep (see the sweep fns) so re-invites / new messages re-push.
let invite_notified = Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
let unread_notified = Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
// Startup cancel-and-recreate (loose-ends v2): wipe this agent's
// matrix todos so stale ones (rooms read / invites resolved while the
// daemon was down) don't linger, then let the first sweep rebuild the
// set to match current reality. Best-effort; the sweep converges.
let _ = wake::send_todo_clear(None, true).await;
let sync_loop: SyncLoop = Box::pin(async move {
sync_client
.sync_with_callback(SyncSettings::default(), move |_response| {
let client = cb_client.clone();
let invite_notified = invite_notified.clone();
let unread_notified = unread_notified.clone();
let tag = tag.clone();
async move {
timeline::sweep_invites(&client, &invite_notified, tag.as_deref()).await;
timeline::sweep_unread(&client, &unread_notified, tag.as_deref()).await;
matrix_sdk::LoopCtrl::Continue
}
})
.await
.context("matrix-sdk sync loop exited")?;
Ok(())
});
Ok(Some((client, sync_loop)))
}