191 lines
7.9 KiB
Nix
191 lines
7.9 KiB
Nix
# In-container forge (Forgejo) integration: the `tea` CLI login
|
||
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
|
||
# avatar sync.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
userName = config.hyperhive.user.name;
|
||
homeDir = "/home/${userName}";
|
||
# Same 512×512 rasterization of the agent icon the matrix avatar
|
||
# sync uses (./matrix.nix — identical derivation, same store path).
|
||
# Only forced when an icon is configured (the avatar-sync unit below
|
||
# is gated on `hyperhive.icon != null`).
|
||
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
|
||
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out
|
||
'';
|
||
in
|
||
{
|
||
options.hyperhive.forge.url = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "http://localhost:3000";
|
||
example = "http://forge.internal:3000";
|
||
description = ''
|
||
Base URL of the hyperhive-managed Forgejo. Used at container
|
||
boot by a oneshot systemd unit that calls
|
||
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
||
shell out to `tea` without an extra auth dance. No-op when the
|
||
forge-token file is missing (i.e. hive-forge isn't running on
|
||
the host).
|
||
'';
|
||
};
|
||
|
||
config = {
|
||
assertions = [
|
||
# hyperhive.forge.url must look like an HTTP URL when non-default.
|
||
{
|
||
assertion =
|
||
config.hyperhive.forge.url == ""
|
||
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|
||
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
|
||
message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")";
|
||
}
|
||
];
|
||
|
||
environment.systemPackages = [
|
||
# tea: gitea/forgejo CLI client. Configured at boot by the
|
||
# tea-login oneshot below if /state/forge-token is present, so
|
||
# claude can `tea repos create`, `tea pulls create`, etc.
|
||
pkgs.tea
|
||
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
|
||
# (view, pr, issue, comment, assign, close, labels, branches, etc.).
|
||
# The per-bin split package — narrow closure, no hivectl/wireguard.
|
||
config.hyperhive.packages.hive-forge
|
||
];
|
||
|
||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||
# docs/conventions.md::Best-effort oneshot services.
|
||
systemd.services.tea-login = {
|
||
description = "configure tea CLI from hive-forge token (best-effort)";
|
||
wantedBy = [ "multi-user.target" ];
|
||
after = [ "local-fs.target" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||
SyslogIdentifier = "tea-login";
|
||
};
|
||
path = [
|
||
pkgs.curl
|
||
pkgs.jq
|
||
pkgs.coreutils
|
||
];
|
||
environment.HOME_DIR = homeDir;
|
||
environment.AGENT_USER = userName;
|
||
script = ''
|
||
# No `set -e`: best-effort posture (see docs pointer above).
|
||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
|
||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||
if [ ! -f "$TOKEN_FILE" ]; then
|
||
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
|
||
exit 0
|
||
fi
|
||
TOKEN=$(cat "$TOKEN_FILE")
|
||
# Resolve the agent username from the forge API.
|
||
USER=$(curl -sf --max-time 5 \
|
||
-H "Authorization: token $TOKEN" \
|
||
"$FORGE_URL/api/v1/user" \
|
||
| jq -r '.login // empty' 2>/dev/null || true)
|
||
if [ -z "$USER" ]; then
|
||
echo "tea-login: could not resolve username from forge API; skipping"
|
||
exit 0
|
||
fi
|
||
# Config under the agent user's home, chown'd to them;
|
||
# service stays root-owned (see docs pointer above).
|
||
CONFIG="$HOME_DIR/.config/tea/config.yml"
|
||
mkdir -p "$(dirname "$CONFIG")" || true
|
||
cat > "$CONFIG" << EOF
|
||
logins:
|
||
- name: forge
|
||
url: $FORGE_URL
|
||
token: $TOKEN
|
||
default: true
|
||
ssh_host: ""
|
||
ssh_key: ""
|
||
insecure: false
|
||
ssh_agent: false
|
||
user: $USER
|
||
preferences:
|
||
editor: false
|
||
flag_defaults:
|
||
remote: ""
|
||
EOF
|
||
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
|
||
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
|
||
'';
|
||
};
|
||
|
||
# Path-trigger sibling: re-fires forge-avatar-sync the moment
|
||
# `<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 re-running the service. See
|
||
# docs/persistence.md::forge-avatar-sync.
|
||
systemd.paths.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) {
|
||
description = "trigger forge-avatar-sync when forge-token appears";
|
||
wantedBy = [ "multi-user.target" ];
|
||
pathConfig.PathExistsGlob = "/agents/*/state/forge-token";
|
||
};
|
||
|
||
# One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract:
|
||
# docs/conventions.md::Best-effort oneshot services.
|
||
# RemainAfterExit = false so the .path trigger above can re-fire
|
||
# this unit when the forge-token arrives after boot. The PNG is
|
||
# rasterized at build time (`iconPng`, shared shape with the matrix
|
||
# avatar sync), so the unit only exists when an icon is configured
|
||
# and needs no librsvg at runtime — Forgejo's Go image library
|
||
# can't decode SVG, hence PNG.
|
||
systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) {
|
||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||
wantedBy = [ "multi-user.target" ];
|
||
after = [ "tea-login.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = false;
|
||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||
SyslogIdentifier = "forge-avatar-sync";
|
||
};
|
||
path = [
|
||
pkgs.curl
|
||
pkgs.coreutils
|
||
pkgs.jq
|
||
];
|
||
script = ''
|
||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||
if [ ! -f "$TOKEN_FILE" ]; then
|
||
echo "forge-avatar-sync: no forge-token found; skipping"
|
||
exit 0
|
||
fi
|
||
TOKEN=$(cat "$TOKEN_FILE")
|
||
IMAGE=$(base64 -w 0 < ${iconPng})
|
||
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
|
||
# raw base64 string, NOT a data URI (data:image/png;base64,...).
|
||
# Use jq to build the payload so the large base64 value is safely quoted.
|
||
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
|
||
RESP=$(curl -sf --max-time 10 \
|
||
-X POST "$FORGE_URL/api/v1/user/avatar" \
|
||
-H "Authorization: token $TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$PAYLOAD" \
|
||
-w "\n%{http_code}" 2>/dev/null || true)
|
||
CODE=$(printf '%s' "$RESP" | tail -1)
|
||
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
|
||
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
|
||
else
|
||
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
|
||
fi
|
||
'';
|
||
};
|
||
};
|
||
}
|