matrix: name the credential after the account it authenticates as
The store path and every identifier around it called this an admin token. It is not one: of ~15 hive-c0re call sites only two need homeserver admin, and the homeserver no longer promotes the account at boot, so the name overstated both what the credential is and what it may do. Renaming it to the account was not enough either. "The `@hive:` token" reads as the token of a hive user, and no such user is provisioned — `@hive:<server_name>` is the appservice registration's own `sender_localpart`, an account the homeserver creates for itself when it loads the registration. So it is the **sender token**: the matrix appservice sender account's access token, at `swarm/services/matrix/sender-token`. The name says what it authenticates as rather than what it may do, which is the part that was wrong. The path has one constructor, and the bao grant, the grant assertion and three unit tests pin its literal independently — so a half-finished rename fails a check rather than leaving the minter and its readers disagreeing at runtime. `tracing` messages are renamed with the code, so the journal reads the way the source does. The host-side file keeps its name (`matrix/access-token`): it carried no admin framing, and renaming it would orphan the file on every deployed hive for nothing. `docs/tools/hivectl-cli.md` is regenerated from the clap tree.
This commit is contained in:
parent
bbb4e471ea
commit
fb9c6122df
18 changed files with 177 additions and 150 deletions
|
|
@ -97,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
|
|||
const HIVE_CHAT_ROOM_TOPIC: &str =
|
||||
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
|
||||
|
||||
/// Host path for the `@hive:` account's matrix access token. Outside every
|
||||
/// Host path for the appservice sender account's matrix access token. Outside every
|
||||
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
||||
#[must_use]
|
||||
pub fn hive_token_path() -> PathBuf {
|
||||
crate::paths::matrix_hive_token()
|
||||
pub fn sender_token_path() -> PathBuf {
|
||||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
|
|
@ -399,12 +399,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
|||
/// but the password file is absent — covers the case where agent state dirs
|
||||
/// were wiped but the homeserver still has the accounts.
|
||||
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
|
||||
let hive_token = read_hive_token()
|
||||
.context("matrix: the @hive: access token is unavailable for auto-recovery")?;
|
||||
let sender_token = read_sender_token()
|
||||
.context("matrix: the matrix sender token is unavailable for auto-recovery")?;
|
||||
let server_name = discover_server_name(client)
|
||||
.await
|
||||
.context("matrix: discover_server_name for auto-recovery")?;
|
||||
let effective_password = reset_user_password(client, &hive_token, name, &server_name)
|
||||
let effective_password = reset_user_password(client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
|
||||
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
|
||||
|
|
@ -424,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
|
|||
/// Look up the room ID for the `#admins:<server>` alias.
|
||||
async fn discover_admin_room_id(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -433,7 +433,7 @@ async fn discover_admin_room_id(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: GET admin room alias")?;
|
||||
|
|
@ -561,7 +561,7 @@ mod extract_new_password_tests {
|
|||
/// Generic over `T` so both password-returning and `()` callers share the loop.
|
||||
async fn admin_room_send_and_poll<T>(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
room_url: &str,
|
||||
command: &str,
|
||||
|
|
@ -574,7 +574,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
|
||||
let send_resp = client
|
||||
.put(&send_url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -601,7 +601,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
let poll_json = client
|
||||
.get(&poll_url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: admin room poll")?
|
||||
|
|
@ -655,16 +655,16 @@ async fn admin_room_send_and_poll<T>(
|
|||
/// Returns the new password; caller is responsible for persisting it.
|
||||
async fn admin_room_reset_password(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
localpart: &str,
|
||||
) -> Result<String> {
|
||||
let room_id = discover_admin_room_id(client, hive_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users reset-password @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
hive_token,
|
||||
sender_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -894,7 +894,7 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
}
|
||||
|
||||
/// Ensure the `@hive:` matrix user exists and that its access token is
|
||||
/// persisted at [`hive_token_path()`].
|
||||
/// persisted at [`sender_token_path()`].
|
||||
///
|
||||
/// **Nothing here depends on registration order, and nothing here is
|
||||
/// privileged.** The account used to have to be the first ever
|
||||
|
|
@ -916,16 +916,16 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
/// minter.
|
||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = hive_token_path();
|
||||
let path = sender_token_path();
|
||||
if path.exists()
|
||||
&& let Ok(existing) = std::fs::read_to_string(&path)
|
||||
&& !existing.trim().is_empty()
|
||||
{
|
||||
tracing::debug!("matrix: the @hive: access token is already present");
|
||||
tracing::debug!("matrix: the sender token is already present");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = stored_hive_token().await {
|
||||
return persist_hive_token(&path, &token);
|
||||
if let Some(token) = stored_sender_token().await {
|
||||
return persist_sender_token(&path, &token);
|
||||
}
|
||||
let password = random_password()?;
|
||||
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await {
|
||||
|
|
@ -973,34 +973,30 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
|
|||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
persist_hive_token(&path, &access_token)
|
||||
persist_sender_token(&path, &access_token)
|
||||
}
|
||||
|
||||
/// Write the `@hive:` account's access token to `path`, 0600, creating the
|
||||
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
||||
/// directory if it is not there.
|
||||
///
|
||||
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
|
||||
/// the store one: the file's mode is the only thing keeping an unprivileged
|
||||
/// reader off the hive's matrix credential, and a second copy of that decision
|
||||
/// is one that can be edited alone.
|
||||
fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(path, format!("{access_token}\n")).with_context(|| {
|
||||
format!(
|
||||
"matrix: write the @hive: access token to {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
std::fs::write(path, format!("{access_token}\n"))
|
||||
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token");
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under
|
||||
/// Fetch the sender token `swarm-matrix-minter` published, under
|
||||
/// this hive's own store identity.
|
||||
///
|
||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||
|
|
@ -1017,15 +1013,15 @@ fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()>
|
|||
/// sweep.
|
||||
///
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_hive_token() -> Option<String> {
|
||||
async fn stored_sender_token() -> Option<String> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())?;
|
||||
let path = swarm_secret_client::matrix::hive_token_path();
|
||||
let path = swarm_secret_client::matrix::sender_token_path();
|
||||
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from");
|
||||
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
|
@ -1034,15 +1030,15 @@ async fn stored_hive_token() -> Option<String> {
|
|||
.await
|
||||
{
|
||||
Ok(credential) if !credential.value.trim().is_empty() => {
|
||||
tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store");
|
||||
tracing::info!(%path, "matrix: taking the sender token from the swarm store");
|
||||
Some(credential.value)
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead");
|
||||
tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead");
|
||||
tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests {
|
|||
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
|
||||
pub async fn promote_user_to_admin(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let room_id = discover_admin_room_id(client, hive_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
hive_token,
|
||||
sender_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -1155,11 +1151,11 @@ pub async fn promote_user_to_admin(
|
|||
/// Returns the new password for use in subsequent `login_user` calls.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, hive_token, server_name, localpart)
|
||||
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
|
||||
|
|
@ -1209,17 +1205,17 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Read the `@hive:` access token from disk. Returns an error if it
|
||||
/// Read the sender token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their homeserver calls on this.
|
||||
pub fn read_hive_token() -> Result<String> {
|
||||
let path = hive_token_path();
|
||||
pub fn read_sender_token() -> Result<String> {
|
||||
let path = sender_token_path();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the @hive: matrix access token was not found at {} — \
|
||||
"the matrix sender token was not found at {} — \
|
||||
ensure hive-c0re has started at least once with matrix enabled \
|
||||
(it provisions the @hive: account on boot)",
|
||||
path.display()
|
||||
|
|
@ -1248,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
|
|||
///
|
||||
/// Name-based (not alias-based) rediscovery keeps the Space free of any
|
||||
/// special-char room alias — the hardcoded plain name is the anchor.
|
||||
async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
|
||||
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1268,7 +1264,12 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
|
|||
let encoded = encode_room_id_for_url(room_id);
|
||||
// Must be an m.space (m.room.create `type`).
|
||||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1281,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
|
|||
}
|
||||
// …and named HIVE_SPACE_NAME (m.room.name `name`).
|
||||
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
||||
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1312,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
|
|||
///
|
||||
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
|
||||
/// or the room-ID file cannot be written.
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result<String> {
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
// 1. Stored room id wins (fast path).
|
||||
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
|
||||
|
|
@ -1325,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re
|
|||
|
||||
// 2. No stored id — rediscover the existing space by its hardcoded name
|
||||
// before creating a new one (prevents duplicate spaces after a wipe).
|
||||
if let Some(room_id) = find_space_by_name(client, hive_token).await {
|
||||
if let Some(room_id) = find_space_by_name(client, sender_token).await {
|
||||
persist_space_room_id(&room_id)?;
|
||||
tracing::info!(%room_id, "matrix: recovered hive space by name");
|
||||
return Ok(room_id);
|
||||
|
|
@ -1341,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1381,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
|
|||
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
|
||||
/// room instead of spawning a duplicate. `None` if the homeserver is
|
||||
/// unreachable or no match exists.
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1401,7 +1402,12 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O
|
|||
let encoded = encode_room_id_for_url(room_id);
|
||||
// Skip the Space itself (and any other m.space).
|
||||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1414,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O
|
|||
}
|
||||
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
|
||||
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
||||
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1454,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
|
|||
/// too, loudly — and a 404 is the expected first-setup case.
|
||||
async fn current_room_state(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
url: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let resp = match client.get(url).bearer_auth(hive_token).send().await {
|
||||
let resp = match client.get(url).bearer_auth(sender_token).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
||||
|
|
@ -1485,7 +1491,7 @@ async fn current_room_state(
|
|||
}
|
||||
}
|
||||
|
||||
/// PUT a state event into `room_id` using the `@hive:` token, **skipping the
|
||||
/// PUT a state event into `room_id` using the sender token, **skipping the
|
||||
/// write when the room already carries identical content**.
|
||||
///
|
||||
/// The read is not an optimisation. A PUT of identical content is a no-op
|
||||
|
|
@ -1500,7 +1506,7 @@ async fn current_room_state(
|
|||
/// missing or divergent link still gets written.
|
||||
async fn set_room_state(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
|
|
@ -1511,7 +1517,7 @@ async fn set_room_state(
|
|||
let encoded_key = encode_room_id_for_url(state_key);
|
||||
let url =
|
||||
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
|
||||
let current = current_room_state(client, hive_token, &url).await;
|
||||
let current = current_room_state(client, sender_token, &url).await;
|
||||
if !state_needs_write(current.as_ref(), content) {
|
||||
tracing::debug!(
|
||||
%room_id,
|
||||
|
|
@ -1522,7 +1528,7 @@ async fn set_room_state(
|
|||
}
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(content)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1559,7 +1565,7 @@ async fn set_room_state(
|
|||
/// link is logged but not fatal (the room still exists + is joinable).
|
||||
pub async fn ensure_hive_chat_room(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
space_room_id: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
|
|
@ -1573,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
|
|||
{
|
||||
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
|
||||
id
|
||||
} else if let Some(id) = find_chat_room_by_name(client, hive_token).await {
|
||||
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
|
||||
persist_chat_room_id(&id)?;
|
||||
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
|
||||
id
|
||||
|
|
@ -1613,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1644,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
if let Err(e) = set_room_state(
|
||||
client,
|
||||
hive_token,
|
||||
sender_token,
|
||||
space_room_id,
|
||||
"m.space.child",
|
||||
&room_id,
|
||||
|
|
@ -1662,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
|
|||
/// account. Idempotent — treats already-member responses as success.
|
||||
async fn invite_to_room(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let user_id = format!("@{localpart}:{server_name}");
|
||||
invite_user_id(client, hive_token, room_id, &user_id).await
|
||||
invite_user_id(client, sender_token, room_id, &user_id).await
|
||||
}
|
||||
|
||||
/// Fetch a user's current membership in a room via the `@hive:` token, or
|
||||
/// Fetch a user's current membership in a room via the sender token, or
|
||||
/// `None` if there is no membership event (never invited) or the lookup
|
||||
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
|
||||
async fn room_membership(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
encoded_room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Option<String> {
|
||||
|
|
@ -1687,7 +1693,12 @@ async fn room_membership(
|
|||
let url = format!(
|
||||
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
|
||||
);
|
||||
let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
// 404 = no membership event yet; anything else we treat as "unknown"
|
||||
// and let the caller fall through to the invite attempt.
|
||||
|
|
@ -1698,13 +1709,13 @@ async fn room_membership(
|
|||
}
|
||||
|
||||
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
|
||||
/// using the `@hive:` token. Idempotent: a user who is already a member or
|
||||
/// using the sender token. Idempotent: a user who is already a member or
|
||||
/// already has a pending invite is left untouched (no fresh invite is sent,
|
||||
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
|
||||
/// from a racing invite is still treated as success.
|
||||
async fn invite_user_id(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<()> {
|
||||
|
|
@ -1716,7 +1727,7 @@ async fn invite_user_id(
|
|||
// Skip the invite entirely when the user is already invited or joined.
|
||||
// Re-POSTing an invite to a pending member re-sends the invite event,
|
||||
// which re-notifies the agent on every provisioning sweep.
|
||||
if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await
|
||||
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
|
||||
&& matches!(membership.as_str(), "invite" | "join")
|
||||
{
|
||||
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
|
||||
|
|
@ -1726,7 +1737,7 @@ async fn invite_user_id(
|
|||
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&serde_json::json!({ "user_id": user_id }))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1759,13 +1770,13 @@ async fn invite_user_id(
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the `@hive:` token or `server_name` can't be read,
|
||||
/// Returns an error if the sender token or `server_name` can't be read,
|
||||
/// the target room can't be resolved (no `--room` and no persisted hive
|
||||
/// space), or the invite POST fails for a reason other than the user
|
||||
/// already being a member / invited.
|
||||
pub async fn invite_user(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
user: &str,
|
||||
room_override: Option<&str>,
|
||||
server_name: &str,
|
||||
|
|
@ -1779,7 +1790,7 @@ pub async fn invite_user(
|
|||
// Resolve the room: explicit override (id or #alias) wins; otherwise
|
||||
// the persisted hive Space.
|
||||
let room_id = match room_override {
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?,
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
|
||||
Some(r) if r.starts_with('!') => r.to_owned(),
|
||||
Some(r) => anyhow::bail!(
|
||||
"matrix: --room {r:?} is neither a room id nor an alias; \
|
||||
|
|
@ -1793,14 +1804,14 @@ pub async fn invite_user(
|
|||
(run the hive-c0re matrix sweep first)",
|
||||
)?,
|
||||
};
|
||||
invite_user_id(client, hive_token, &room_id, &user_id).await?;
|
||||
invite_user_id(client, sender_token, &room_id, &user_id).await?;
|
||||
Ok(room_id)
|
||||
}
|
||||
|
||||
/// Resolve a `#alias:server` to its room id via the directory API.
|
||||
async fn resolve_room_alias(
|
||||
client: &reqwest::Client,
|
||||
hive_token: &str,
|
||||
sender_token: &str,
|
||||
alias: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -1808,7 +1819,7 @@ async fn resolve_room_alias(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(hive_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("matrix: GET directory for {alias}"))?;
|
||||
|
|
@ -1899,10 +1910,10 @@ pub async fn ensure_all() -> bool {
|
|||
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
|
||||
let mut ok = true;
|
||||
// server_name is needed to form full Matrix user IDs for invites.
|
||||
let hive_token = match read_hive_token() {
|
||||
let sender_token = match read_sender_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)");
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1914,7 +1925,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
return false;
|
||||
}
|
||||
};
|
||||
let room_id = match ensure_hive_space(client, &hive_token).await {
|
||||
let room_id = match ensure_hive_space(client, &sender_token).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
||||
|
|
@ -1922,14 +1933,20 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
}
|
||||
};
|
||||
// Invite @hive first, then all agents.
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&sender_token,
|
||||
&room_id,
|
||||
HIVE_LOCALPART,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
|
||||
ok = false;
|
||||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await {
|
||||
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
|
||||
ok = false;
|
||||
}
|
||||
|
|
@ -1940,11 +1957,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
|
||||
// without this the Space is empty. The restricted join rule additionally
|
||||
// lets the operator (a Space member) join from the Space hierarchy.
|
||||
match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await {
|
||||
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
|
||||
Ok(chat_room_id) => {
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&hive_token,
|
||||
&sender_token,
|
||||
&chat_room_id,
|
||||
HIVE_LOCALPART,
|
||||
&server_name,
|
||||
|
|
@ -1956,7 +1973,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await
|
||||
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
|
||||
ok = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
|
||||
//!
|
||||
//! Historically these were flat string literals scattered across many
|
||||
//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`,
|
||||
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
|
||||
//! …). This module is the **single Rust-side source** for every host
|
||||
//! path — the strictly host-side ones grouped into subdirs (`db/`,
|
||||
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
|
||||
|
|
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
|
|||
agent_identity_root().join(name)
|
||||
}
|
||||
|
||||
/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive
|
||||
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
|
||||
/// Space room id, per-agent password creds). The shared registration
|
||||
/// token is bind-mounted into the tuwunel container via nix and stays
|
||||
/// at its own path (tracked separately).
|
||||
|
|
@ -137,9 +137,9 @@ pub fn matrix_dir() -> PathBuf {
|
|||
state_root().join("matrix")
|
||||
}
|
||||
|
||||
/// `matrix/access-token` — the `@hive:` account's matrix access token.
|
||||
/// `matrix/access-token` — the matrix appservice sender account's access token.
|
||||
#[must_use]
|
||||
pub fn matrix_hive_token() -> PathBuf {
|
||||
pub fn matrix_sender_token() -> PathBuf {
|
||||
matrix_dir().join("access-token")
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
|
|||
"forge-agent-configs-avatar-set",
|
||||
forge_config_org_avatar_marker(),
|
||||
),
|
||||
("matrix-hive-token", matrix_hive_token()),
|
||||
("matrix-sender-token", matrix_sender_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async fn stream_agent_status(
|
|||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + `@hive:` tokens and the matrix creds dir. Each op returns the
|
||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -780,7 +780,7 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
crate::matrix::ensure_hive_user(&client, &as_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::hive_token_path();
|
||||
let path = crate::matrix::sender_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: the @{}: user is provisioned",
|
||||
|
|
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
|
||||
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let hive_token = crate::matrix::read_hive_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name)
|
||||
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
|
|
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
|||
|
||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let hive_token = crate::matrix::read_hive_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name)
|
||||
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix invite {user}"))?;
|
||||
let target = if user.starts_with('@') {
|
||||
|
|
@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
|
|||
|
||||
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let hive_token = crate::matrix::read_hive_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::reset_user_password(&client, &hive_token, name, &server_name)
|
||||
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
|
|
|
|||
Loading…
Reference in a new issue