diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index a8be30b5..636d77dc 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -165,8 +165,9 @@ Tree-shape version: `systemd.globalEnvironment` in `meta.rs` for every container (manager included), so all token/state paths resolve through `$HYPERHIVE_STATE_DIR`. The harness-base shell scripts -(tea-login, forge-avatar-sync) simplified from glob+for loops to a -direct `$HYPERHIVE_STATE_DIR/` read. +(tea-login, forge-avatar-sync, matrix-avatar-sync) simplified +from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/` +read. ### E — prompt + tools diff --git a/docs/conventions.md b/docs/conventions.md index 72ce9b31..56fe68d6 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -474,10 +474,10 @@ the state dir at provisioning time: - `forge-avatar-sync` — uploads `hyperhive.icon` SVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments. - -(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon` -sets it over its live authenticated Client; see -`docs/persistence.md::matrix avatar`.) +- `matrix-avatar-sync` — same idea for matrix profile avatars + (two-step `media upload` → `set avatar_url` dance — see + `docs/persistence.md::matrix-avatar-sync` for the protocol + detail). 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 state (idempotent uploads, idempotent config rewrites). Used by the `.path` watchers that re-fire on token appearance (see - `docs/persistence.md::Matrix per-agent daemon`). + `docs/persistence.md::matrix-avatar-sync`). The artefact lives under the agent user's home where applicable (`~/.config/tea/config.yml`) and is chown'd to that user, but the diff --git a/docs/persistence.md b/docs/persistence.md index 6cae08b0..c958220d 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -396,35 +396,28 @@ agent containers come up. Without the path-trigger sibling 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 the service so the daemon comes alive in the same boot cycle as -provisioning. The same token watcher also drives avatar setting: on a -restart the daemon re-runs each account's bring-up, which sets the -avatar (see below). +provisioning. `matrix-avatar-sync.path` uses the same pattern for +the icon-upload oneshot. -### matrix avatar (set by the daemon over the live Client) +### matrix-avatar-sync (two-step media + profile dance) -The agent icon (`hyperhive.icon`, an SVG) is published as each matrix -account's profile avatar by `hive-matrix-daemon` itself -(`hive-matrix-mcp::client::sync_avatar`), not a separate oneshot. After -the daemon builds + restores an account's `Client` (authenticated, -pointed at that account's resolved homeserver), it calls matrix-sdk's -`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. +Mirrors the forge-avatar oneshot's shape (`docs/conventions.md:: +Best-effort oneshot services`) but differs in protocol: matrix +avatars are a two-step `POST /media/r0/upload` → `PUT +/profile//avatar_url` dance, both authenticated by the +`access_token` written by `hive-c0re::matrix::ensure_user_for` to +`/matrix-token`. -Nix rasterizes the SVG to a 512x512 PNG at build time (`iconPng`, via -librsvg) and forwards its store path as `HIVE_ICON_PNG` on the daemon -unit, gated on `hyperhive.icon != null`. No icon configured → the env is -unset → `sync_avatar` returns early and no avatar is set. +Triggered by EITHER boot (`wantedBy = multi-user.target`) OR the +sibling `matrix-avatar-sync.path` firing on token appearance. Both +paths re-run the oneshot idempotently — running the avatar set +twice is harmless. -Idempotency is **per-account**: an `avatar-icon-hash` file in each -account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and -skips the upload when unchanged, because every upload mints a fresh -`mxc://` URI that emits a profile state event in every joined room — -re-uploading identical bytes is timeline spam. A dashboard-provisioned -account gets its avatar when the `systemd.paths.hive-matrix-daemon` token -watcher restarts the daemon (which re-runs the per-account bring-up), so -no separate avatar trigger is needed. Avatar failures are swallowed -(logged, non-fatal) so they never break account bring-up or sync. +Critically: **`RemainAfterExit = false`** (not the more common +`true` for oneshots). systemd treats `RemainAfterExit = true` +oneshots as "still running" after the first exit, so the second +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. diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 942762b1..797782eb 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -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 the per-agent page header, as the page favicon, and uploaded to the agent's Forgejo profile avatar (via the `forge-avatar-sync` boot -unit) and Matrix profile avatar (set by `hive-matrix-daemon` over its -live Client). Commit the SVG next to `agent.nix` in the config repo -and reference it as a relative path. +unit) and Matrix profile avatar (via `matrix-avatar-sync`). Commit +the SVG next to `agent.nix` in the config repo and reference it as a +relative path. When `null` (the default), the agent falls back to the shared hyperhive branding mark. The harness serves whichever icon is active diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 22753764..8977f160 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -1011,7 +1011,7 @@ where # `environment.variables` only writes /etc/environment (login # shells); `systemd.globalEnvironment` is the analogue for # systemd units so tea-login / forge-avatar-sync / - # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` + # matrix-avatar-sync etc. can read `$HYPERHIVE_STATE_DIR` # without each service having to redeclare it. environment.variables = { HIVE_LABEL = name; diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 5303341e..56891bba 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -184,63 +184,3 @@ async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDevi let device_id: OwnedDeviceId = device_id_raw.into(); 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; - } - }; - // FNV-1a content hash — enough to answer "did the icon change?" and - // deterministic across Rust/std versions (unlike `DefaultHasher`, whose - // output may change between toolchains, spuriously mismatching the - // persisted hash and re-uploading the same avatar). No crypto strength - // needed here, so no dependency on a hashing crate. - let hash = { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in &bytes { - h ^= u64::from(b); - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - format!("{h:016x}") - }; - 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)"); - } - } -} diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 57043435..5d8d2e0e 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -196,10 +196,6 @@ async fn bring_up_account( client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) .await .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()); let sync_client = client.clone(); diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 1da1bf1a..b355e20a 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -61,15 +61,6 @@ let # cannot drift). Matches the daemon's own built-in default # (`paths::DEFAULT_HOMESERVER`). 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 { # Shared scaffolding for every hyperhive harness container. @@ -415,7 +406,7 @@ in daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment hive-c0re provisions the token (same path-trigger shape - as `forge-avatar-sync`). + as `matrix-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected @@ -1442,8 +1433,8 @@ in }; # Path-trigger sibling: re-fires forge-avatar-sync the moment - # `/forge-token` appears. Mirrors the hive-matrix-daemon - # token-watcher pattern — on first agent deployment the container boots before + # `/forge-token` appears. Mirrors the matrix-avatar-sync + # pattern — on first agent deployment the container boots before # hive-c0re has provisioned the forge-token, so the service fires # too early and exits with "no forge-token found". Without this path # unit, RemainAfterExit=true would prevent systemd from ever @@ -1562,12 +1553,6 @@ in // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } ) 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 = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; @@ -1657,6 +1642,124 @@ in pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; }; + # Path-trigger sibling: re-fires matrix-avatar-sync the moment + # `/matrix-token` appears. Same first-boot-ordering pattern + # as hive-matrix-daemon above. + systemd.paths.matrix-avatar-sync = { + description = "trigger matrix-avatar-sync when matrix-token appears"; + wantedBy = [ "multi-user.target" ]; + 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 + # 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//state`. + TOKEN_FILE="$HYPERHIVE_STATE_DIR/matrix-token" + if [ ! -f "$TOKEN_FILE" ]; then + echo "matrix-avatar-sync: no matrix-token at $TOKEN_FILE; 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. The hash file lives + # in $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so + # purge + re-provision gets a fresh upload). Delete to force + # re-upload. + HASH_FILE="$HYPERHIVE_STATE_DIR/matrix-avatar-icon-hash" + CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1) + if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE" 2>/dev/null)" = "$CURRENT_HASH" ]; then + echo "matrix-avatar-sync: icon unchanged (hash matches); skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + # Local tuwunel reachable on shared host netns at the + # default matrix-spec port. Override via + # `hyperhive.matrix.url` if the operator runs the + # homeserver elsewhere. + 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" + # Persist hash so subsequent runs skip the upload when the + # icon hasn't changed. + echo "$CURRENT_HASH" > "$HASH_FILE" + 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. # Best-effort oneshot (always exit 0):