feat(#2035): auto-discover dashboard-provisioned matrix accounts via token+homeserver sidecar

This commit is contained in:
damocles 2026-06-27 00:11:13 +02:00 committed by mara
commit 3b0a914487
6 changed files with 112 additions and 11 deletions

View file

@ -244,7 +244,9 @@ pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) ->
}
};
if let Err(e) = crate::priv_client::write_agent_matrix_token(agent, &token, Some(account)).await
if let Err(e) =
crate::priv_client::write_agent_matrix_token(agent, &token, Some(account), Some(homeserver))
.await
{
return error_response(&format!("matrix-account-login: write token failed: {e:#}"));
}

View file

@ -709,7 +709,7 @@ pub async fn ensure_user_for(
// unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container.
crate::priv_client::write_agent_matrix_token(name, &access_token, None)
crate::priv_client::write_agent_matrix_token(name, &access_token, None, None)
.await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token");

View file

@ -261,15 +261,22 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()
/// `<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?)
}

View file

@ -78,14 +78,13 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
state_dir: paths::matrix_state_dir(),
homeserver: None,
};
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
return Ok(vec![hive]);
};
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
let mut accounts = Vec::with_capacity(extras.len() + 1);
accounts.push(hive);
accounts.extend(extras);
let mut accounts = vec![hive];
if let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
accounts.extend(extras);
}
// Reject duplicate names among the *configured* set (hive + extras).
let mut seen = std::collections::HashSet::new();
for a in &accounts {
if !seen.insert(a.name.as_str()) {
@ -95,9 +94,84 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
);
}
}
// 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.
let configured: std::collections::HashSet<String> =
accounts.iter().map(|a| a.name.clone()).collect();
for disc in discover_token_accounts() {
if !configured.contains(&disc.name) {
accounts.push(disc);
}
}
Ok(accounts)
}
/// Scan the agent state dir for extra matrix accounts provisioned via the
/// dashboard login form: each is a `matrix-token-<name>` file plus a
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
/// [`AccountCfg`] per discovered account that has BOTH files — a token
/// without a sidecar is skipped, because the homeserver is then unknown and
/// defaulting to the hive homeserver would be wrong for an external account.
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
/// authoritative.
fn discover_token_accounts() -> Vec<AccountCfg> {
let token_path = paths::token_file();
let Some(state_dir) = token_path.parent() else {
return Vec::new();
};
let Ok(rd) = std::fs::read_dir(state_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
// `matrix-token` (no suffix) is the hive account, handled separately;
// only `matrix-token-<name>` files are extra accounts.
let Some(name) = fname.strip_prefix("matrix-token-") else {
continue;
};
if name.is_empty() {
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-login via the dashboard to write it)"
);
continue;
};
out.push(AccountCfg {
name: name.to_owned(),
token_file: entry.path(),
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
homeserver: Some(homeserver),
});
}
out
}
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
/// absent / empty.
fn read_account_homeserver(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
json.get("homeserver")?
.as_str()
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
/// Live status of one matrix account, as reported by [`Registry::list`]
/// (the `list_accounts` daemon op). Only accounts that successfully
/// restored a session appear, so `live` is always `true` today; the

View file

@ -247,6 +247,7 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
ref agent_name,
ref token,
ref account,
ref homeserver,
} => {
validate_agent_name(agent_name)?;
// Build the token filename. `None` → the hive account's
@ -262,7 +263,17 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
format!("matrix-token-{a}")
}
};
write_agent_state_file(agent_name, &filename, &format!("{token}\n"))
let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?;
// For an extra account, persist its homeserver in a sidecar
// (`matrix-account-<a>.json`) so the daemon can auto-discover the
// account without a static `matrixAccounts` config entry. Only
// when both `account` and `homeserver` are present; the account
// suffix is already validated above.
if let (Some(a), Some(hs)) = (account, homeserver) {
let meta = serde_json::json!({ "homeserver": hs }).to_string();
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
}
Ok(res)
}
PrivRequest::RestartMatrixDaemon { ref agent_name } => {

View file

@ -389,6 +389,13 @@ pub enum PrivRequest {
/// 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>,
},
/// Restart `hive-matrix-daemon.service` inside an agent container via