Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3249a390 | ||
|
|
c99fa714d9 | ||
|
|
63e9e80232 | ||
|
|
c29dfed9f4 | ||
|
|
ef079bb6b1 |
8 changed files with 124 additions and 157 deletions
|
|
@ -165,9 +165,8 @@ 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, matrix-avatar-sync) simplified
|
||||
from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/<token>`
|
||||
read.
|
||||
(tea-login, forge-avatar-sync) simplified from glob+for loops to a
|
||||
direct `$HYPERHIVE_STATE_DIR/<token>` read.
|
||||
|
||||
### E — prompt + tools
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- `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).
|
||||
|
||||
(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon`
|
||||
sets it over its live authenticated Client; see
|
||||
`docs/persistence.md::matrix avatar`.)
|
||||
|
||||
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-avatar-sync`).
|
||||
`docs/persistence.md::Matrix per-agent daemon`).
|
||||
|
||||
The artefact lives under the agent user's home where applicable
|
||||
(`~/.config/tea/config.yml`) and is chown'd to that user, but the
|
||||
|
|
|
|||
|
|
@ -396,28 +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
|
||||
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. `matrix-avatar-sync.path` uses the same pattern for
|
||||
the icon-upload oneshot.
|
||||
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).
|
||||
|
||||
### 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::
|
||||
Best-effort oneshot services`) but differs in protocol: matrix
|
||||
avatars are a two-step `POST /media/r0/upload` → `PUT
|
||||
/profile/<user_id>/avatar_url` dance, both authenticated by the
|
||||
`access_token` written by `hive-c0re::matrix::ensure_user_for` to
|
||||
`<state>/matrix-token`.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (via `matrix-avatar-sync`). Commit
|
||||
the SVG next to `agent.nix` in the config repo and reference it as a
|
||||
relative path.
|
||||
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.
|
||||
|
||||
When `null` (the default), the agent falls back to the shared
|
||||
hyperhive branding mark. The harness serves whichever icon is active
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
# 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.
|
||||
environment.variables = {
|
||||
HIVE_LABEL = name;
|
||||
|
|
|
|||
|
|
@ -184,3 +184,63 @@ 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,10 @@ 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();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ 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.
|
||||
|
|
@ -406,7 +415,7 @@ in
|
|||
daemon auto-skips when `<state>/matrix-token` is missing,
|
||||
and a `systemd.paths` watcher restarts it the moment
|
||||
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,
|
||||
send_reaction, send_reply, mark_read, list_rooms,
|
||||
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
|
||||
# `<state>/forge-token` appears. Mirrors the matrix-avatar-sync
|
||||
# pattern — on first agent deployment the container boots before
|
||||
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
|
||||
# token-watcher 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
|
||||
|
|
@ -1553,6 +1562,12 @@ 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";
|
||||
|
|
@ -1642,124 +1657,6 @@ in
|
|||
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
|
||||
# read them without accessing the container's /etc/ from the host.
|
||||
# Best-effort oneshot (always exit 0):
|
||||
|
|
|
|||
Loading…
Reference in a new issue