diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index c239d0b8..6d0e5cfa 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -244,7 +244,9 @@ pub(super) async fn post_matrix_account_login(Form(f): Form) -> } }; - 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:#}")); } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 9de89df9..bc611661 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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"); diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index c8ac97af..9e6c2014 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -261,15 +261,22 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<() /// `/matrix-token-` 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 `/matrix-account-.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?) } diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index 70f0cc22..f6be4ca6 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -78,14 +78,13 @@ pub fn configured() -> anyhow::Result> { 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 = 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 = 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> { ); } } + // Append dashboard-provisioned accounts (a `matrix-token-` file + + // its `matrix-account-.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 = + 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-` file plus a +/// `matrix-account-.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 { + 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-` 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": ""}` 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 { + 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 diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index ca227aea..e57bbe23 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -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-.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 } => { diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 06556b7e..49fb99f0 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -389,6 +389,13 @@ pub enum PrivRequest { /// Extra-account suffix. `None` → `matrix-token` (the hive account); /// `Some(name)` → `matrix-token-` (validated as a plain ident). account: Option, + /// Homeserver URL for an extra account. When `Some` (only meaningful + /// alongside `account: Some`), hive-priv also writes the sidecar + /// `matrix-account-.json` (`{"homeserver": }`, 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, }, /// Restart `hive-matrix-daemon.service` inside an agent container via