mars: give L.U.N.A. direct git+tea access to the homelab repo

Provisions a dedicated PR-tier gitea account (luna) with branch protection
restricting master push/merge/approve to darman only, then wires git and
tea directly into the hermes-agent container (mounted from the host's Nix
store, credential-store + tea login set up by a host-side prepare oneshot,
repo cloned inside Hermes's own writable sandbox root at
/opt/data/workspace/homelab). Replaces an earlier standalone MCP-server
approach, scrapped in favor of direct CLI access for simplicity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
This commit is contained in:
2026-08-22 20:50:35 +02:00
co-authored by Claude Sonnet 5
parent b4917c0daa
commit 3c1f3e5fc3
4 changed files with 197 additions and 8 deletions
+84 -6
View File
@@ -1,4 +1,4 @@
{ config, ... }:
{ config, pkgs, ... }:
# Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix,
# see its git history / b5fa599 / 713d91d for the terra->jupiter->mars
@@ -15,10 +15,13 @@
# runtime — no redeploy needed except to bump the pinned digest below.
#
# Security posture:
# - Only two paths reachable: its own local state dir, and the small
# shared "dropbox" below (via the jupiter samba mount) for darman to
# hand files to Hermes — nothing else on jupiter's array is reachable
# if a command goes wrong or gets injected via Telegram/tool output.
# - Reachable paths: its own local state dir, the small shared "dropbox"
# (via the jupiter samba mount) for darman to hand files to Hermes, and
# — new — a clone of THIS repo at ${workspaceDir}/homelab plus `git`/
# `tea` (logged in as the `luna` gitea account, PR-tier only — see
# services/dev/gitea.nix). Nothing else on jupiter's array or the host
# is reachable if a command goes wrong or gets injected via
# Telegram/tool output.
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
# TELEGRAM_ALLOWED_USERS.
# - Runs as a rootful podman container (services/containers.nix) with its
@@ -26,6 +29,12 @@
# host-level debugging only (`hermes ...` alias below, needs sudo since
# the container itself runs under root's podman, not darman's rootless
# one).
# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly
# chose this over a purpose-built MCP server (tried first, scrapped —
# see git history) in favor of simplicity. The backstop is entirely
# server-side: gitea's branch protection on `master` (only darman can
# push/merge/approve there) is what actually keeps a bad or injected
# command from reaching the base branch, not anything client-side here.
#
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on
# jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth
@@ -69,6 +78,16 @@ let
# change if state ever gets migrated over.
hermesUid = "986";
hermesGid = "983";
# luna's own working copy of this repo (git+PR account provisioned in
# services/dev/gitea.nix). Lives under hermesHome specifically so it falls
# inside HERMES_WRITE_SAFE_ROOT=/opt/data — Hermes's own file-editing
# tools can reach it the same way they reach anything else it manages,
# without a separate bind mount or sandbox root.
workspaceDir = "${hermesHome}/workspace";
repoDir = "${workspaceDir}/homelab";
giteaHost = "git.mgaction.town";
giteaRepo = "darman/homelab";
in
{
# Browsing convenience (ssh access to the bind-mounted local state) — does
@@ -91,15 +110,55 @@ in
# podman requires the bind-mount source to already exist (no auto-create),
# and the dropbox lives on the CIFS mount below — mkdir there works fine
# over cifs, no server-side (jupiter) config needed.
#
# Also provisions luna's git/tea access: writes a git credential-store file
# and runs `tea logins add` INTO hermesHome (i.e. paths that appear at
# /opt/data/... once the container is up), and clones this repo if it
# isn't already there. All of this runs on the HOST as root, before the
# container starts — the container's own entrypoint is what fixes
# ownership to HERMES_UID/HERMES_GID on first boot (same mechanism
# already relied on for the rest of hermesHome; nothing new here).
#
# Delete-then-add for the tea login (not a "does it exist" check): tea can
# leave a login entry behind even when `add` reports failure (e.g. a token
# missing a scope errors out AFTER the entry is written — observed
# directly against the real instance during the first version of this
# setup). Delete-then-add is idempotent either way and picks up a rotated
# token for free.
systemd.services.hermes-agent-prepare-dirs = {
description = "Create Hermes state dirs before the container starts";
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
before = [ "podman-hermes-agent.service" ];
wantedBy = [ "podman-hermes-agent.service" ];
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
path = [ pkgs.git pkgs.tea ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p ${hermesHome}
mkdir -p ${dropboxDir}
mkdir -p ${workspaceDir}
export HOME=${hermesHome}
export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig
export XDG_CONFIG_HOME=${hermesHome}/.config
token_file=${config.sops.secrets.gitea_luna_token.path}
# Never embed the token in the remote URL (would land in
# repoDir/.git/config in plaintext) the credential helper reads it
# from this file instead.
install -m 0600 /dev/null ${hermesHome}/.git-credentials
printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \
> ${hermesHome}/.git-credentials
git config --global credential.helper "store --file=${hermesHome}/.git-credentials"
git config --global user.name "luna"
git config --global user.email "luna@${giteaHost}"
if [ ! -d ${repoDir}/.git ]; then
git clone "https://${giteaHost}/${giteaRepo}.git" ${repoDir}
fi
tea logins delete luna 2>/dev/null || true
GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \
--name luna --url "https://${giteaHost}" --no-version-check
'';
};
@@ -114,11 +173,30 @@ in
volumes = [
"${hermesHome}:/opt/data"
"${dropboxDir}:/opt/data/dropbox"
# git/tea for luna: the image doesn't ship `tea` (and shouldn't be
# trusted to have a known-good `git` either), so both come from this
# host's Nix store instead — mounted read-only at fixed PATH-visible
# locations. /nix/store itself has to come along too since both
# binaries are dynamically linked against paths inside it; the store
# is read-only content-addressed build output, not a source of
# secrets, so mounting the whole thing read-only costs nothing beyond
# the two specific binaries actually being reachable.
"/nix/store:/nix/store:ro"
"${pkgs.git}/bin/git:/usr/local/bin/git:ro"
"${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro"
];
environment = {
HERMES_UID = hermesUid;
HERMES_GID = hermesGid;
TZ = "Europe/Berlin";
# Point git/tea at the config the prepare-dirs oneshot wrote into
# hermesHome (visible here as /opt/data/...) — the credential-store
# helper, the luna gitea login, and (implicitly, via HOME not being
# overridden) darman's Hermes state stays wherever it already was.
GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig";
XDG_CONFIG_HOME = "/opt/data/.config";
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks
# (ahead of config.yaml's `timezone` key) — the container has no host
# /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in
+12
View File
@@ -35,4 +35,16 @@
TELEGRAM_ALLOWED_USERS=15151223
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
'';
# luna's own gitea push token (services/dev/gitea.nix provisions the
# account + PR-tier repo access on jupiter; this is the per-user token
# generated once via `gitea admin user generate-access-token --username
# luna --scopes write:repository,read:user` on jupiter — read:user is
# required, `tea logins add` fails without it). Read directly by
# hermes-agent.nix's prepare-dirs oneshot (default root:root owner is
# fine — that oneshot already runs as root) to set up a git
# credential-store file and a `tea` login, both written into hermesHome
# so they're visible inside the container at /opt/data/....
# restartUnits re-provisions both on rotation, without a full mars deploy.
sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ];
}
+3 -2
View File
@@ -4,6 +4,7 @@ tailscale_authkey: ENC[AES256_GCM,data:An+OPDZF9kmemzoDhZPo7yMljksCz3yE/W9I1EAwt
opencode_go_api_key: ENC[AES256_GCM,data:x7V6iRrP6UMvMAYh/25bcrE10MHhL9lasCYRHiQ3PIDI6aL+uXP0/YpfrRPY+60m5Yv+Bd7+9aWTWdAVu1laSNjJGg==,iv:EmEAig+fSMYX+g77UpkiQ0USxUYOfFWX4WjIj9NA9N8=,tag:Pr+EZW6uDTSGjng8iG2SZw==,type:str]
telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL/JVXcsdQesD1PLmKp6vK9g==,iv:m1oqKlcesvhMLtndyp/XxsUAy0YpEsSulPDK0V+Wh0A=,tag:zvLcxcQ+A4fQUht5GkL2Qw==,type:str]
hermes_dashboard_oidc_client_secret: ENC[AES256_GCM,data:IMPNTPMKO+b7eyV4hyGfnvH1/i+W4IPDNjncoyB1oIV8WaB6nOJn0sSEuTUCKB94K+Y7bsVQU0zpbKdIYOdGqgmPzwMCsScxMt4SewTmiiqWxv6SQFf4EzMxgXqjMvH8PWDzLcI2C2tI/KcVS251iqRViOTFe1/tkm+mV8sJmEI=,iv:F/rOUDmJZoGPS9fObAni5ntyOqbbhMWDPdHGLTexwlA=,tag:ALf98DmB0JziGspZMiLCiw==,type:str]
gitea_luna_token: ENC[AES256_GCM,data:EgSgzXFlYHN1yAlpjBBjSxacVYO9mhe1TBtAjNMZDEPxkeizB5O8Bw==,iv:pKN6bz7mBV3HxqBdnJi6ah17bukhd+sXeItojngT0HE=,tag:1wCfPK+MJb+P/S/kq2czeQ==,type:str]
sops:
age:
- enc: |
@@ -24,7 +25,7 @@ sops:
oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q==
-----END AGE ENCRYPTED FILE-----
recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk
lastmodified: "2026-08-21T23:24:48Z"
mac: ENC[AES256_GCM,data:p/vtMVfVWfgP7+xa8sbbdYLeLCYbjvthHoexmKU0RyIl2jv5bp9ldD1F1G8U+v+8+Re44DClcDtoNXrIHlreFx3hbH9sF2tnL4+oI2os7Yn+nmWZ6J1ngS2Hz+qspWtFt+up/TkP2BsTzoas5ZstJckOW0AkeKEYIT1zQmzcBtE=,iv:O2VoZK1DmRSJIR/bC538Ley1fkZqjI0L80YpjUU9mPo=,tag:xO9wzAocmxoxrIHcnBRGXQ==,type:str]
lastmodified: "2026-08-22T18:28:46Z"
mac: ENC[AES256_GCM,data:Y/QbEoRG2pJ+tz919+kSEfCs6HsjTHmiaO5xWuDhuVXO71Sm+8vx2OQwCnEHWA1FnFoQgBJWJFlAA4yMiFyjtE3Ark9Uxxi07DXYfXJ/B64DBbrxJMQSKVfCxi8KlExbKyL87FSuUwmSeYgE2DIydmOGDv0P+Q0kn5GJ1T6lJOU=,iv:iX9OJMJK3xTsGh8ZLXzZWUj5mZg7jGR3GnvMeR2lXvA=,tag:CvsoVF1vdf4fQmLE3NR9hw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+98
View File
@@ -14,6 +14,12 @@ let
# and whitelisted to push past branch protection. Add a repo here and
# redeploy — no manual UI clicking needed.
ciBotRepos = [ "darman/hypr-chrome" ];
# Repos where luna (Hermes Agent's own gitea identity — see below) gets PR-tier
# access: Write collaborator (so she can push feature branches and open PRs)
# but explicitly walled off `master`'s push/merge/approve whitelists so
# nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ];
in
{
services.gitea = {
@@ -166,4 +172,96 @@ in
'') ciBotRepos}
'';
};
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
# Hermes runs on mars, takes instructions over Telegram, and can be
# prompt-injected via tool output — a dedicated account with its own
# scoped, revocable token keeps that blast radius off darman's own
# credentials, and the branch-protection whitelists below keep it off
# `master` entirely regardless of what the token can technically do.
# She gets Write collaborator access (needed to push a branch and open a
# PR against the same repo — this instance has no fork workflow), but:
# - enable_push + enable_push_whitelist(darman only): nobody but darman
# can push straight to master; luna can only land on a side branch.
# - enable_merge_whitelist(darman only): opening a PR is not the same
# as merging one — only darman can click merge.
# - required_approvals=1 + enable_approvals_whitelist(darman only):
# an approval has to come from darman specifically, not luna
# rubber-stamping her own PR from a second identity.
# This is provisioning parity with ci-bot only (account + collaborator +
# branch protection) — it does NOT wire a token into mars/hermes-agent.nix
# yet; that's a separate step once luna actually has git tooling to call.
#
# luna's own push token (used by whatever git tooling gets wired into
# hermes-agent.nix later) is generated once, the same way ci-bot's was:
# su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
# --username luna --scopes write:repository'
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
# since luna isn't a CI workflow running inside gitea, she's an external
# agent calling out to it.
systemd.services.gitea-luna-provision = {
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
admin_token="$(cat "$TOKEN_FILE")"
auth=(-H "Authorization: token $admin_token")
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
if ! curl -fs "''${auth[@]}" "$api/users/luna" >/dev/null 2>&1; then
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
--username luna \
--email luna@${config.services.gitea.settings.server.DOMAIN} \
--random-password --must-change-password=false
fi
${lib.concatMapStringsSep "\n" (repo: ''
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/repos/${repo}/collaborators/luna" \
-d '{"permission":"write"}'
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
protect_body="$(jq -n '{
enable_push: true,
enable_push_whitelist: true,
push_whitelist_usernames: ["darman"],
enable_merge_whitelist: true,
merge_whitelist_usernames: ["darman"],
required_approvals: 1,
enable_approvals_whitelist: true,
approvals_whitelist_username: ["darman"]
}')"
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$default_branch" >/dev/null 2>&1; then
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/${repo}/branch_protections/$default_branch" \
-d "$protect_body"
else
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X POST "$api/repos/${repo}/branch_protections" \
-d "$(echo "$protect_body" | jq --arg b "$default_branch" '. + {branch_name: $b}')"
fi
'') lunaRepos}
'';
};
}