From 7e041e607906335ad39f301b384722503ca7f67c Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 29 May 2026 12:34:24 +0200 Subject: [PATCH] harness-base: matrix-avatar-sync oneshot mirroring forge avatar (#548 phase 2.5) --- nix/templates/harness-base.nix | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index f3c718d5..274c5346 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -745,6 +745,102 @@ ''; }; + # One-shot: upload the agent's configured icon to its matrix profile + # avatar so the icon shows up next to messages in matrix rooms (#548 + # phase 2.5). Mirrors the forge-avatar-sync flow above, only differs + # in protocol: matrix avatars are a two-step `media upload` → `set + # avatar_url` dance, both authenticated by the access_token written + # by hive-c0re's `matrix::ensure_user_for`. No-op when the icon + # isn't configured, the matrix token isn't present, or the + # homeserver isn't reachable. *Always* exits 0. + 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 = true; + }; + 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 + # Token written by `hive-c0re::matrix::ensure_user_for` to the + # agent's bind-mounted state dir. Either appears at the legacy + # `/state/` (manager) or per-agent `/agents//state/` path. + TOKEN_FILE="" + for f in /state/matrix-token /agents/*/state/matrix-token; do + if [ -f "$f" ]; then + TOKEN_FILE="$f" + break + fi + done + if [ -z "$TOKEN_FILE" ]; then + echo "matrix-avatar-sync: no matrix-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + # Local tuwunel reachable on shared host netns at the + # default matrix-spec port. Override via the future + # `hyperhive.matrix.url` if the operator ever runs the + # homeserver elsewhere (deferred to #548 phase 4). + MATRIX_URL=http://localhost:8008 + # 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: whoami failed or homeserver unreachable; skipping" + exit 0 + fi + # Rasterize SVG → PNG (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 + # 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) + rm -f "$PNG" + if [ -z "$MXC" ]; then + echo "matrix-avatar-sync: media upload failed; skipping" + exit 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: avatar set on $USER_ID" + else + echo "matrix-avatar-sync: avatar PUT returned HTTP $CODE — skipping (non-fatal)" + fi + ''; + }; + # Write declared dashboardLinks to the state dir so hive-c0re can read # them without accessing the container's /etc/ from the host. # Runs every boot; idempotent (overwrite). Always exits 0.