Three fixes to how luna's git/tea credentials are set up on mars, all found
against the running instance on 2026-08-23.
Drop the host-side clone. hermes-agent-prepare-dirs used to clone this repo
into ${hermesHome}/workspace/homelab, but nothing ever told luna at runtime
that it was there — she self-manages config/profiles/memories, so a path
baked into this file never reached her. She searched /opt/data/homelab and
/workspace, found neither, and concluded she had no repo at all. The
credentials are what actually grant access; any checkout is hers to make
anywhere inside HERMES_WRITE_SAFE_ROOT. The stale directory left by the old
version is deliberately not cleaned up, just unmanaged from here on.
Point credential.helper at the CONTAINER's path. It was written as the host
path (${hermesHome}/.git-credentials), which does not exist inside the
container where git actually reads the config — broken this way from 3c1f3e5
until now. Nothing host-side consumes those credentials any more, so the
container's view is the only one that has to be right; added `containerHome`
to make the distinction explicit at the point of use.
Chown what the oneshot writes. The image's cont-init only chowns the top
level of hermesHome and its own state — it does not recurse into the
root-owned 0600 files this unit drops there (.git-credentials, and tea's
config.yml, which tea also writes 0600), even though it runs afterwards. The
symptom was not an error but an absence: git reported no credential helper
and tea no login. Uses `if` rather than `[ -d x ] && chown` because under
`set -e` a false test on the left of an && list aborts the unit.
gitea.nix carries the matching comment updates: the luna provisioning unit is
server-side only, and her token needs write:repository,write:issue,read:user.
write:issue is the one that is easy to miss — a pull request IS an issue in
gitea's data model, so /pulls endpoints gate on the issue scope category and
`tea pr create` fails with write:repository alone even though clone, fetch
and push all work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
366 lines
16 KiB
Nix
366 lines
16 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
|
|
# ZimaOS docker instance straight into stateDir's default layout, so no
|
|
# import step is needed — just chown it to the gitea user after first deploy
|
|
# (currently darman:users from the CIFS copy):
|
|
# chown -R gitea:gitea /mnt/data/AppData/gitea
|
|
#
|
|
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
|
|
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
|
|
# :222 — the unpriv gitea user can't bind <1024).
|
|
let
|
|
# Repos where the ci-bot account (see below) should be a Write collaborator
|
|
# 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" ];
|
|
|
|
# Forward every Gitea event to the generic Mars relay. Hermes owns the
|
|
# decision about which events matter and what to do with them.
|
|
giteaWebhookEvents = [
|
|
"create"
|
|
"delete"
|
|
"fork"
|
|
"push"
|
|
"issues"
|
|
"issue_assign"
|
|
"issue_label"
|
|
"issue_milestone"
|
|
"issue_comment"
|
|
"pull_request"
|
|
"pull_request_assign"
|
|
"pull_request_label"
|
|
"pull_request_milestone"
|
|
"pull_request_comment"
|
|
"pull_request_review_approved"
|
|
"pull_request_review_rejected"
|
|
"pull_request_review_comment"
|
|
"pull_request_sync"
|
|
"pull_request_review_request"
|
|
"wiki"
|
|
"repository"
|
|
"release"
|
|
"package"
|
|
"status"
|
|
"workflow_run"
|
|
"workflow_job"
|
|
];
|
|
in
|
|
{
|
|
services.gitea = {
|
|
enable = true;
|
|
stateDir = "/mnt/data/AppData/gitea";
|
|
lfs.enable = true;
|
|
|
|
settings = {
|
|
repository = {
|
|
DEFAULT_BRANCH = "master";
|
|
};
|
|
server = {
|
|
DOMAIN = "git.mgaction.town";
|
|
SSH_DOMAIN = "git.mgaction.town";
|
|
# https, not http: neptun's Caddy terminates TLS for this name. Gitea
|
|
# builds its absolute URLs (clone buttons, redirects, webhooks) from
|
|
# ROOT_URL, so an http:// value hands out downgraded links.
|
|
ROOT_URL = "https://git.mgaction.town/";
|
|
HTTP_PORT = 3000;
|
|
START_SSH_SERVER = true;
|
|
SSH_PORT = 2222;
|
|
SSH_LISTEN_PORT = 2222;
|
|
};
|
|
service = {
|
|
DISABLE_REGISTRATION = true;
|
|
};
|
|
actions = {
|
|
ENABLED = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
users.users.gitea.extraGroups = [ "users" ];
|
|
|
|
# Runner instance registered against this same gitea. Jobs run in containers
|
|
# (podman, via services/containers.nix — already enabled on jupiter), one
|
|
# image per requested `runs-on` label using the catthehacker act-compatible
|
|
# images (same ones upstream `act`/Forgejo docs recommend).
|
|
#
|
|
# tokenFile points at an env file rendered by sops (TOKEN=<registration
|
|
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the
|
|
# secret never lands in the Nix store. The registration token itself is NOT
|
|
# generated by this module — it comes from gitea once Actions is enabled:
|
|
# su gitea -s /bin/sh -c \
|
|
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
|
|
# then written into secrets/jupiter.yaml as gitea_runner_token.
|
|
services.gitea-actions-runner.instances.jupiter = {
|
|
enable = true;
|
|
name = "jupiter";
|
|
url = "https://git.mgaction.town/";
|
|
tokenFile = config.sops.templates."gitea-runner.env".path;
|
|
labels = [
|
|
"ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"
|
|
"ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04"
|
|
];
|
|
};
|
|
|
|
# ci-bot: dedicated account CI workflows push as (kept separate from any
|
|
# human account so its own PAT can be scoped/rotated/revoked independently).
|
|
# Collaborator access + branch-protection push-whitelisting have no CLI or
|
|
# config-file surface in gitea — only the HTTP API — so this is the one
|
|
# part of the setup that stays imperative even though it's nix-triggered:
|
|
# a oneshot that PUTs/PATCHes the API into the desired state on every
|
|
# deploy where its script changed (adding a repo to `ciBotRepos` and
|
|
# redeploying is enough to pick it up; it won't self-heal a manual revert
|
|
# done via the web UI unless the unit is also restarted).
|
|
#
|
|
# Auth for those API calls is darman's OWN token (named
|
|
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
|
|
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
|
|
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
|
|
# the collaborator/branch-protection endpoints; write:user is additionally
|
|
# needed to push ci-bot's token below as a secret on darman's own account.
|
|
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
|
|
#
|
|
# ci-bot's own push token (separate secret, ci_bot_token) is generated
|
|
# once via:
|
|
# su gitea -s /bin/sh -c \
|
|
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
|
# --username ci-bot --scopes write:repository'
|
|
# and this service pushes it into gitea itself as a user-level Actions
|
|
# secret (CI_BOT_TOKEN, on darman's account — see the PUT below) so
|
|
# workflows in ciBotRepos can push as ci-bot without a per-repo secret.
|
|
systemd.services.gitea-ci-bot-provision = {
|
|
description = "Provision ci-bot gitea account + 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;
|
|
CI_BOT_TOKEN_FILE = config.sops.secrets.gitea_ci_bot_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/ci-bot" >/dev/null 2>&1; then
|
|
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
|
|
--username ci-bot \
|
|
--email ci-bot@${config.services.gitea.settings.server.DOMAIN} \
|
|
--random-password --must-change-password=false
|
|
fi
|
|
|
|
# No instance-wide secret scope exists in Gitea (it's an open feature
|
|
# request) - a user-level secret on darman's own account is the closest
|
|
# equivalent, since every repo below is owned directly by darman, not
|
|
# an org, and repo-level secrets fall back to user-level when unset.
|
|
ci_bot_token="$(cat "$CI_BOT_TOKEN_FILE")"
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PUT "$api/user/actions/secrets/CI_BOT_TOKEN" \
|
|
-d "$(jq -n --arg data "$ci_bot_token" '{data: $data}')"
|
|
|
|
${lib.concatMapStringsSep "\n" (repo: ''
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PUT "$api/repos/${repo}/collaborators/ci-bot" \
|
|
-d '{"permission":"write"}'
|
|
|
|
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
|
|
|
|
# ci-bot needs push access on every branch a workflow might commit
|
|
# back to (currently just `develop`, where version-bump.yml pushes),
|
|
# in addition to whatever the repo's actual default branch is.
|
|
branches="$(printf '%s\n' "$default_branch" develop | sort -u)"
|
|
for branch in $branches; do
|
|
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$branch" >/dev/null 2>&1; then
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PATCH "$api/repos/${repo}/branch_protections/$branch" \
|
|
-d '{"enable_push":true,"enable_push_whitelist":true,"push_whitelist_usernames":["ci-bot"]}'
|
|
else
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X POST "$api/repos/${repo}/branch_protections" \
|
|
-d "{\"branch_name\":\"$branch\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"ci-bot\"]}"
|
|
fi
|
|
done
|
|
'') 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 covers the SERVER side only (account + collaborator + branch
|
|
# protection). The client side — git/tea inside the hermes-agent container,
|
|
# and the token below — lives in hosts/mars/hermes-agent.nix.
|
|
#
|
|
# luna's own push token 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,write:issue,read:user'
|
|
# 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.
|
|
#
|
|
# **write:issue is NOT optional and is easy to miss**: this token started
|
|
# life as `write:repository` alone, which clones, fetches and pushes
|
|
# branches perfectly well — so everything looks fine right up until the
|
|
# first `tea pr create`, which gitea rejects with
|
|
# token scope=write:repository,read:user required=read:issue
|
|
# A pull request IS an issue in gitea's data model, so every /pulls
|
|
# endpoint is gated on the *issue* scope category, not the repository one.
|
|
# write:issue covers it (in gitea's scope model write:X implies read:X);
|
|
# read:issue alone would satisfy the GET half and then fail the POST that
|
|
# actually opens the PR. The error names read:issue only because that's
|
|
# the first check tea trips on. Rotating the token is free — the prepare
|
|
# oneshot on mars does delete-then-add for the tea login on every start.
|
|
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}
|
|
'';
|
|
};
|
|
|
|
# Register the generic Gitea webhook. This is idempotent: it updates the
|
|
# existing hook for the relay target or creates it when absent. Event policy
|
|
# belongs to Hermes, so the source sends the complete Gitea event set.
|
|
systemd.services.gitea-hermes-webhook-provision = {
|
|
description = "Provision Gitea webhook for Hermes events";
|
|
after = [ "gitea.service" ];
|
|
requires = [ "gitea.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.curl pkgs.jq ];
|
|
environment = {
|
|
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
|
|
SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.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")"
|
|
secret="$(cat "$SECRET_FILE")"
|
|
auth=(-H "Authorization: token $admin_token")
|
|
target="http://mars.orbit.sol:8645/gitea"
|
|
|
|
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
|
|
# above: After=gitea.service only means the process started, not that it
|
|
# is serving HTTP yet. Without this the first curl below fails under
|
|
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
|
|
# the webhook silently unregistered until someone restarts the unit.
|
|
for _ in $(seq 1 30); do
|
|
curl -fs "$api/version" >/dev/null 2>&1 && break
|
|
sleep 1
|
|
done
|
|
|
|
# The secret goes to curl on stdin (--data @-), never in argv: this unit
|
|
# runs as the gitea user on a multi-user box, and a request body passed
|
|
# with -d is world-readable in /proc/<pid>/cmdline for its lifetime.
|
|
body="$(jq -n --arg url "$target" --arg secret "$secret" \
|
|
--argjson events '${builtins.toJSON giteaWebhookEvents}' \
|
|
'{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')"
|
|
|
|
hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks" \
|
|
| jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')"
|
|
if [ -n "$hook_id" ]; then
|
|
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null
|
|
else
|
|
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null
|
|
fi
|
|
'';
|
|
};
|
|
}
|