feat(#2050): set matrix avatar in the daemon over the live client

This commit is contained in:
damocles 2026-06-27 19:35:21 +02:00 committed by mara
commit c99fa714d9
8 changed files with 118 additions and 210 deletions

View file

@ -165,9 +165,8 @@ Tree-shape version:
`systemd.globalEnvironment` in `meta.rs` for every container `systemd.globalEnvironment` in `meta.rs` for every container
(manager included), so all token/state paths resolve through (manager included), so all token/state paths resolve through
`$HYPERHIVE_STATE_DIR`. The harness-base shell scripts `$HYPERHIVE_STATE_DIR`. The harness-base shell scripts
(tea-login, forge-avatar-sync, matrix-avatar-sync) simplified (tea-login, forge-avatar-sync) simplified from glob+for loops to a
from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/<token>` direct `$HYPERHIVE_STATE_DIR/<token>` read.
read.
### E — prompt + tools ### E — prompt + tools

View file

@ -474,10 +474,10 @@ the state dir at provisioning time:
- `forge-avatar-sync` — uploads `hyperhive.icon` SVG to the - `forge-avatar-sync` — uploads `hyperhive.icon` SVG to the
agent's Forgejo profile, so the icon shows up on commits / PRs / agent's Forgejo profile, so the icon shows up on commits / PRs /
issue comments. issue comments.
- `matrix-avatar-sync` — same idea for matrix profile avatars
(two-step `media upload``set avatar_url` dance — see (The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon`
`docs/persistence.md::matrix-avatar-sync` for the protocol sets it over its live authenticated Client; see
detail). `docs/persistence.md::matrix avatar`.)
Shape contract — every one of these: Shape contract — every one of these:
@ -498,7 +498,7 @@ Shape contract — every one of these:
5. **Re-runnable**: a second invocation produces the same final 5. **Re-runnable**: a second invocation produces the same final
state (idempotent uploads, idempotent config rewrites). Used state (idempotent uploads, idempotent config rewrites). Used
by the `.path` watchers that re-fire on token appearance (see by the `.path` watchers that re-fire on token appearance (see
`docs/persistence.md::matrix-avatar-sync`). `docs/persistence.md::Matrix per-agent daemon`).
The artefact lives under the agent user's home where applicable The artefact lives under the agent user's home where applicable
(`~/.config/tea/config.yml`) and is chown'd to that user, but the (`~/.config/tea/config.yml`) and is chown'd to that user, but the

View file

@ -396,39 +396,35 @@ agent containers come up. Without the path-trigger sibling
first time it ran and the MCP would have no backend until the next first time it ran and the MCP would have no backend until the next
restart. The `.path` unit makes the appearance of the token re-fire restart. The `.path` unit makes the appearance of the token re-fire
the service so the daemon comes alive in the same boot cycle as the service so the daemon comes alive in the same boot cycle as
provisioning. `matrix-avatar-sync.path` uses the same pattern for provisioning. The same token watcher also drives avatar setting: on a
the icon-upload oneshot. restart the daemon re-runs each account's bring-up, which sets the
avatar (see below).
### matrix-avatar-sync (two-step media + profile dance) ### matrix avatar (set by the daemon over the live Client)
Mirrors the forge-avatar oneshot's shape (`docs/conventions.md:: The agent icon (`hyperhive.icon`, an SVG) is published as each matrix
Best-effort oneshot services`) but differs in protocol: matrix account's profile avatar by `hive-matrix-daemon` itself
avatars are a two-step `POST /media/r0/upload` → `PUT (`hive-matrix-mcp::client::sync_avatar`), not a separate oneshot. After
/profile/<user_id>/avatar_url` dance, both authenticated by the the daemon builds + restores an account's `Client` (authenticated,
`access_token` written by `hive-c0re::matrix::ensure_user_for` to pointed at that account's resolved homeserver), it calls matrix-sdk's
`<state>/matrix-token`. `account().upload_avatar()` — one call that uploads the media and sets
`avatar_url`. Because it reuses the live Client, there is no hardcoded
homeserver URL, no token re-read, and no token-file globbing: the daemon
already iterates every configured + dashboard-discovered account in its
bring-up loop, so the avatar is set for **every** account.
The oneshot syncs the avatar for **every** account, not just the Nix rasterizes the SVG to a 512x512 PNG at build time (`iconPng`, via
hive-internal one: a `sync_one` shell function runs the upload + set librsvg) and forwards its store path as `HIVE_ICON_PNG` on the daemon
dance for the bare `matrix-token` (`main`, against `localhost:8008`) unit, gated on `hyperhive.icon != null`. No icon configured → the env is
and then for each dashboard-provisioned `matrix-token-<name>`, reading unset → `sync_avatar` returns early and no avatar is set.
that account's homeserver from its `matrix-account-<name>.json` sidecar
(mirrors `accounts::discover_token_accounts`). Idempotency is
**per-account**: a `matrix-avatar-icon-hash[-<name>]` file each, because
the `mxc://` URI is homeserver-scoped so every account uploads to its own
homeserver independently.
Triggered by EITHER boot (`wantedBy = multi-user.target`) OR the Idempotency is **per-account**: an `avatar-icon-hash` file in each
sibling `matrix-avatar-sync.path` firing on token appearance — its glob account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and
covers both `matrix-token` and `matrix-token-*`, so an account logged in skips the upload when unchanged, because every upload mints a fresh
after boot gets its avatar without a restart. Both paths re-run the `mxc://` URI that emits a profile state event in every joined room —
oneshot idempotently — running the avatar set twice is harmless. re-uploading identical bytes is timeline spam. A dashboard-provisioned
account gets its avatar when the `systemd.paths.hive-matrix-daemon` token
Critically: **`RemainAfterExit = false`** (not the more common watcher restarts the daemon (which re-runs the per-account bring-up), so
`true` for oneshots). systemd treats `RemainAfterExit = true` no separate avatar trigger is needed. Avatar failures are swallowed
oneshots as "still running" after the first exit, so the second (logged, non-fatal) so they never break account bring-up or sync.
trigger from the `.path` watcher becomes a no-op. Setting it to
`false` lets re-fires actually re-execute. The trade-off is the
service unit shows `inactive (dead)` between fires — visible in
`journalctl` but harmless; the `.path` unit drives the lifecycle.

View file

@ -317,9 +317,9 @@ hyperhive.icon = ./icon.svg; # default: null (falls back to shared hyperhive lo
Path to an SVG file used as this agent's visual identity — shown in Path to an SVG file used as this agent's visual identity — shown in
the per-agent page header, as the page favicon, and uploaded to the the per-agent page header, as the page favicon, and uploaded to the
agent's Forgejo profile avatar (via the `forge-avatar-sync` boot agent's Forgejo profile avatar (via the `forge-avatar-sync` boot
unit) and Matrix profile avatar (via `matrix-avatar-sync`). Commit unit) and Matrix profile avatar (set by `hive-matrix-daemon` over its
the SVG next to `agent.nix` in the config repo and reference it as a live Client). Commit the SVG next to `agent.nix` in the config repo
relative path. and reference it as a relative path.
When `null` (the default), the agent falls back to the shared When `null` (the default), the agent falls back to the shared
hyperhive branding mark. The harness serves whichever icon is active hyperhive branding mark. The harness serves whichever icon is active

View file

@ -1011,7 +1011,7 @@ where
# `environment.variables` only writes /etc/environment (login # `environment.variables` only writes /etc/environment (login
# shells); `systemd.globalEnvironment` is the analogue for # shells); `systemd.globalEnvironment` is the analogue for
# systemd units so tea-login / forge-avatar-sync / # systemd units so tea-login / forge-avatar-sync /
# matrix-avatar-sync etc. can read `$HYPERHIVE_STATE_DIR` # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
# without each service having to redeclare it. # without each service having to redeclare it.
environment.variables = { environment.variables = {
HIVE_LABEL = name; HIVE_LABEL = name;

View file

@ -184,3 +184,57 @@ async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDevi
let device_id: OwnedDeviceId = device_id_raw.into(); let device_id: OwnedDeviceId = device_id_raw.into();
Ok((user_id, device_id)) Ok((user_id, device_id))
} }
/// Best-effort: set this account's matrix avatar from the rasterized icon
/// PNG. The path comes from `HIVE_ICON_PNG` (a nix-built derivation the
/// harness forwards into the daemon env; absent when no icon resolves).
/// Idempotent via a per-account `avatar-icon-hash` file in the account's
/// sdk `state_dir` — a re-upload only happens when the icon bytes change,
/// so daemon restarts don't re-spam the homeserver.
///
/// This replaces the old `matrix-avatar-sync` systemd curl oneshot: the
/// daemon already holds an authenticated `Client` pointed at the correct
/// homeserver, so it uploads over the live connection — no hardcoded URL,
/// no token re-read, no token-file globbing. Any failure is logged and
/// swallowed: avatar trouble must never break account bring-up or sync.
pub async fn sync_avatar(client: &Client, state_dir: &Path, account: &str) {
let Ok(png_path) = std::env::var("HIVE_ICON_PNG") else {
return; // no icon forwarded → nothing to sync
};
let bytes = match fs::read(&png_path).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(account, path = %png_path, error = %e, "matrix avatar: icon PNG unreadable; skipping");
return;
}
};
// A non-crypto content hash is enough to answer "did the icon change?".
let hash = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
format!("{:016x}", h.finish())
};
let hash_file = state_dir.join("avatar-icon-hash");
if let Ok(prev) = fs::read_to_string(&hash_file).await
&& prev.trim() == hash
{
tracing::debug!(account, "matrix avatar: icon unchanged; skipping upload");
return;
}
match client
.account()
.upload_avatar(&mime::IMAGE_PNG, bytes)
.await
{
Ok(mxc) => {
tracing::info!(account, mxc = %mxc, "matrix avatar set");
if let Err(e) = fs::write(&hash_file, &hash).await {
tracing::warn!(account, error = %e, "matrix avatar: set ok but hash write failed (re-uploads next start)");
}
}
Err(e) => {
tracing::warn!(account, error = %format!("{e:#}"), "matrix avatar: upload failed; skipping (non-fatal)");
}
}
}

View file

@ -196,6 +196,10 @@ async fn bring_up_account(
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
.await .await
.with_context(|| format!("build matrix client for account {}", cfg.name))?; .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;
timeline::install_message_handler(&client, hyperhive_socket.to_path_buf(), tag.clone()); timeline::install_message_handler(&client, hyperhive_socket.to_path_buf(), tag.clone());
let sync_client = client.clone(); let sync_client = client.clone();

View file

@ -61,6 +61,15 @@ let
# cannot drift). Matches the daemon's own built-in default # cannot drift). Matches the daemon's own built-in default
# (`paths::DEFAULT_HOMESERVER`). # (`paths::DEFAULT_HOMESERVER`).
matrixUrlDefault = "http://localhost:8008"; matrixUrlDefault = "http://localhost:8008";
# Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
# Replaces the old `matrix-avatar-sync` curl oneshot. Only forced when an
# icon is configured — the `HIVE_ICON_PNG` daemon-env entry is gated on
# `hyperhive.icon != null`, so this binding stays lazy when no icon is set.
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out
'';
in in
{ {
# Shared scaffolding for every hyperhive harness container. # Shared scaffolding for every hyperhive harness container.
@ -406,7 +415,7 @@ in
daemon auto-skips when `<state>/matrix-token` is missing, daemon auto-skips when `<state>/matrix-token` is missing,
and a `systemd.paths` watcher restarts it the moment and a `systemd.paths` watcher restarts it the moment
hive-c0re provisions the token (same path-trigger shape hive-c0re provisions the token (same path-trigger shape
as `matrix-avatar-sync`). as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm, - exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms, send_reaction, send_reply, mark_read, list_rooms,
list_room_members, read_room) to claude via an auto-injected list_room_members, read_room) to claude via an auto-injected
@ -1433,8 +1442,8 @@ in
}; };
# Path-trigger sibling: re-fires forge-avatar-sync the moment # Path-trigger sibling: re-fires forge-avatar-sync the moment
# `<state>/forge-token` appears. Mirrors the matrix-avatar-sync # `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
# pattern — on first agent deployment the container boots before # token-watcher pattern — on first agent deployment the container boots before
# hive-c0re has provisioned the forge-token, so the service fires # hive-c0re has provisioned the forge-token, so the service fires
# too early and exits with "no forge-token found". Without this path # too early and exits with "no forge-token found". Without this path
# unit, RemainAfterExit=true would prevent systemd from ever # unit, RemainAfterExit=true would prevent systemd from ever
@ -1553,6 +1562,12 @@ in
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) config.hyperhive.matrixAccounts ) config.hyperhive.matrixAccounts
); );
}
# Rasterized agent icon path for the daemon's avatar sync. Only set
# when an icon is configured; absent → the daemon skips avatar setting
# (hive-matrix-mcp::client::sync_avatar returns early on unset env).
// lib.optionalAttrs (config.hyperhive.icon != null) {
HIVE_ICON_PNG = "${iconPng}";
}; };
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
@ -1642,166 +1657,6 @@ in
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
}; };
# Path-trigger sibling: re-fires matrix-avatar-sync the moment a
# token appears - both the hive-internal `matrix-token` and any
# dashboard-provisioned extra `matrix-token-<name>` (so an external
# account logged in after boot gets its avatar without a restart).
# Same first-boot-ordering pattern as hive-matrix-daemon above.
systemd.paths.matrix-avatar-sync = {
description = "trigger matrix-avatar-sync when a matrix token appears";
wantedBy = [ "multi-user.target" ];
# Single `matrix-token*` glob (same as hive-matrix-daemon above) so
# both the hive-internal `matrix-token` and any dashboard-provisioned
# `matrix-token-<name>` re-fire the sync. A list value here is
# unverified for `systemd.paths.*.pathConfig`, and the wildcard
# covers both cases anyway.
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
};
# One-shot: hyperhive.icon → matrix profile avatar (two-step media
# upload + set avatar_url). Shape contract:
# docs/conventions.md::Best-effort oneshot services. Protocol +
# why RemainAfterExit = false:
# docs/persistence.md::matrix-avatar-sync.
systemd.services.matrix-avatar-sync = {
description = "sync agent icon to matrix profile avatar (best-effort)";
wantedBy = [ "multi-user.target" ];
# No `after = [ "tea-login.service" ]` — matrix has no
# equivalent prerequisite; we just need the homeserver up.
serviceConfig = {
Type = "oneshot";
# RemainAfterExit = false so the .path trigger can re-fire
# the unit (see docs/persistence.md::matrix-avatar-sync).
RemainAfterExit = false;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "matrix-avatar-sync";
};
path = [
pkgs.curl
pkgs.coreutils
pkgs.jq
pkgs.librsvg
];
script = ''
ICON=/etc/hyperhive/icon.svg
if [ ! -f "$ICON" ]; then
echo "matrix-avatar-sync: no icon configured; skipping"
exit 0
fi
# Hash-based idempotency: skip the upload if the icon hasn't
# changed since the last successful sync. Every upload mints a
# new mxc:// URI which triggers a profile state event in every
# joined room — uploading the same bytes again produces timeline
# spam without changing the visible avatar. Hash files live in
# $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so
# purge + re-provision gets a fresh upload). One hash file per
# account (the mxc:// URI is homeserver-scoped, so each account
# uploads to its own homeserver independently). Delete to force
# a re-upload.
CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1)
# Rasterize SVG → PNG ONCE; the same bytes are reused for every
# account (matrix media accepts any image type but we already
# standardise on PNG for the forge sync).
PNG=$(mktemp --suffix=.png)
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
echo "matrix-avatar-sync: rsvg-convert failed; skipping"
rm -f "$PNG"
exit 0
fi
# sync_one <token-file> <matrix-url> <hash-suffix> <label>
# Two-step media upload + avatar_url set for a single account.
# Best-effort: every failure path logs + returns 0 so one bad
# account never aborts the others.
sync_one() {
token_file=$1
matrix_url=$2
hash_suffix=$3
label=$4
if [ ! -f "$token_file" ]; then
return 0
fi
hash_file="$HYPERHIVE_STATE_DIR/matrix-avatar-icon-hash$hash_suffix"
if [ -f "$hash_file" ] && [ "$(cat "$hash_file" 2>/dev/null)" = "$CURRENT_HASH" ]; then
echo "matrix-avatar-sync[$label]: icon unchanged (hash matches); skipping"
return 0
fi
token=$(cat "$token_file")
# whoami → user_id. Needed to scope the avatar set call.
# Tolerant of the homeserver being unreachable (`-f` makes
# curl fail on 4xx/5xx; `|| true` swallows the exit).
user_id=$(curl -sf --max-time 5 \
-H "Authorization: Bearer $token" \
"$matrix_url/_matrix/client/v3/account/whoami" 2>/dev/null \
| jq -r '.user_id // empty' || true)
if [ -z "$user_id" ]; then
echo "matrix-avatar-sync[$label]: whoami failed or homeserver unreachable; skipping"
return 0
fi
# Step 1: upload bytes → mxc:// URI.
mxc=$(curl -sf --max-time 10 \
-X POST "$matrix_url/_matrix/media/v3/upload" \
-H "Authorization: Bearer $token" \
-H "Content-Type: image/png" \
--data-binary "@$PNG" 2>/dev/null \
| jq -r '.content_uri // empty' || true)
if [ -z "$mxc" ]; then
echo "matrix-avatar-sync[$label]: media upload failed; skipping"
return 0
fi
# Step 2: set avatar_url on the profile.
payload=$(jq -n --arg url "$mxc" '{avatar_url:$url}')
code=$(curl -s --max-time 10 \
-X PUT "$matrix_url/_matrix/client/v3/profile/$user_id/avatar_url" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload" \
-o /dev/null -w "%{http_code}" 2>/dev/null || true)
if [ "$code" = "200" ]; then
echo "matrix-avatar-sync[$label]: avatar set on $user_id"
# Persist hash so subsequent runs skip the upload when the
# icon hasn't changed.
echo "$CURRENT_HASH" > "$hash_file"
else
echo "matrix-avatar-sync[$label]: avatar PUT returned HTTP $code skipping (non-fatal)"
fi
}
# Hive-internal `main` account: bare `matrix-token`, written by
# `hive-c0re::matrix::ensure_user_for` to the agent's
# bind-mounted state dir. $HYPERHIVE_STATE_DIR is set system-wide
# by the meta flake (systemd.globalEnvironment) to
# `/agents/<name>/state`. Local tuwunel reachable on the shared
# host netns at the default matrix-spec port.
sync_one "$HYPERHIVE_STATE_DIR/matrix-token" "http://localhost:8008" "" "main"
# Dashboard-provisioned extra accounts: each is a
# `matrix-token-<name>` file plus a `matrix-account-<name>.json`
# sidecar carrying its (possibly external) homeserver. Mirrors
# hive-matrix-mcp::accounts::discover_token_accounts — a token
# without a sidecar is skipped because the homeserver is unknown.
for token_file in "$HYPERHIVE_STATE_DIR"/matrix-token-*; do
[ -f "$token_file" ] || continue
name=''${token_file##*/matrix-token-}
[ -n "$name" ] || continue
sidecar="$HYPERHIVE_STATE_DIR/matrix-account-$name.json"
if [ ! -f "$sidecar" ]; then
echo "matrix-avatar-sync[$name]: no homeserver sidecar; skipping"
continue
fi
homeserver=$(jq -r '.homeserver // empty' "$sidecar" 2>/dev/null || true)
if [ -z "$homeserver" ]; then
echo "matrix-avatar-sync[$name]: empty homeserver in sidecar; skipping"
continue
fi
sync_one "$token_file" "$homeserver" "-$name" "$name"
done
rm -f "$PNG"
'';
};
# Write declared dashboardLinks to the state dir so hive-c0re can # Write declared dashboardLinks to the state dir so hive-c0re can
# read them without accessing the container's /etc/ from the host. # read them without accessing the container's /etc/ from the host.
# Best-effort oneshot (always exit 0): # Best-effort oneshot (always exit 0):