Compare commits

..
8 changed files with 157 additions and 124 deletions

View file

@ -165,8 +165,9 @@ 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) simplified from glob+for loops to a (tea-login, forge-avatar-sync, matrix-avatar-sync) simplified
direct `$HYPERHIVE_STATE_DIR/<token>` read. from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/<token>`
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
(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon` (two-step `media upload``set avatar_url` dance — see
sets it over its live authenticated Client; see `docs/persistence.md::matrix-avatar-sync` for the protocol
`docs/persistence.md::matrix avatar`.) detail).
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 per-agent daemon`). `docs/persistence.md::matrix-avatar-sync`).
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,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 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. The same token watcher also drives avatar setting: on a provisioning. `matrix-avatar-sync.path` uses the same pattern for
restart the daemon re-runs each account's bring-up, which sets the the icon-upload oneshot.
avatar (see below).
### 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 Mirrors the forge-avatar oneshot's shape (`docs/conventions.md::
account's profile avatar by `hive-matrix-daemon` itself Best-effort oneshot services`) but differs in protocol: matrix
(`hive-matrix-mcp::client::sync_avatar`), not a separate oneshot. After avatars are a two-step `POST /media/r0/upload` → `PUT
the daemon builds + restores an account's `Client` (authenticated, /profile/<user_id>/avatar_url` dance, both authenticated by the
pointed at that account's resolved homeserver), it calls matrix-sdk's `access_token` written by `hive-c0re::matrix::ensure_user_for` to
`account().upload_avatar()` — one call that uploads the media and sets `<state>/matrix-token`.
`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.
Nix rasterizes the SVG to a 512x512 PNG at build time (`iconPng`, via Triggered by EITHER boot (`wantedBy = multi-user.target`) OR the
librsvg) and forwards its store path as `HIVE_ICON_PNG` on the daemon sibling `matrix-avatar-sync.path` firing on token appearance. Both
unit, gated on `hyperhive.icon != null`. No icon configured → the env is paths re-run the oneshot idempotently — running the avatar set
unset → `sync_avatar` returns early and no avatar is set. twice is harmless.
Idempotency is **per-account**: an `avatar-icon-hash` file in each Critically: **`RemainAfterExit = false`** (not the more common
account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and `true` for oneshots). systemd treats `RemainAfterExit = true`
skips the upload when unchanged, because every upload mints a fresh oneshots as "still running" after the first exit, so the second
`mxc://` URI that emits a profile state event in every joined room — trigger from the `.path` watcher becomes a no-op. Setting it to
re-uploading identical bytes is timeline spam. A dashboard-provisioned `false` lets re-fires actually re-execute. The trade-off is the
account gets its avatar when the `systemd.paths.hive-matrix-daemon` token service unit shows `inactive (dead)` between fires — visible in
watcher restarts the daemon (which re-runs the per-account bring-up), so `journalctl` but harmless; the `.path` unit drives the lifecycle.
no separate avatar trigger is needed. Avatar failures are swallowed
(logged, non-fatal) so they never break account bring-up or sync.

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 (set by `hive-matrix-daemon` over its unit) and Matrix profile avatar (via `matrix-avatar-sync`). Commit
live Client). Commit the SVG next to `agent.nix` in the config repo the SVG next to `agent.nix` in the config repo and reference it as a
and reference it as a relative path. 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 /
# 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. # without each service having to redeclare it.
environment.variables = { environment.variables = {
HIVE_LABEL = name; HIVE_LABEL = name;

View file

@ -184,63 +184,3 @@ 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;
}
};
// 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)");
}
}
}

View file

@ -196,10 +196,6 @@ 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,15 +61,6 @@ 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.
@ -415,7 +406,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 `forge-avatar-sync`). as `matrix-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
@ -1442,8 +1433,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 hive-matrix-daemon # `<state>/forge-token` appears. Mirrors the matrix-avatar-sync
# token-watcher pattern — on first agent deployment the container boots before # 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
@ -1562,12 +1553,6 @@ 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";
@ -1657,6 +1642,124 @@ in
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
}; };
# Path-trigger sibling: re-fires matrix-avatar-sync the moment
# `<state>/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/<name>/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 # 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):