From 50f83971def8e316bbb8bee6954410e4e57e8bc7 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sun, 23 Aug 2026 06:30:28 +0200 Subject: [PATCH] hermes: stop provisioning luna a working copy, fix her git/tea access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa --- hosts/mars/hermes-agent.nix | 86 ++++++++++++++++++++++++++----------- services/dev/gitea.nix | 24 ++++++++--- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 9a05a0a..f78e861 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -17,11 +17,17 @@ # Security posture: # - 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. +# `git`/`tea`, logged in as the `luna` gitea account (PR-tier only — +# see services/dev/gitea.nix). No working copy of this repo is +# provisioned for her: an earlier version cloned one into +# ${hermesHome}/workspace/homelab, dropped again because nothing ever +# told her at runtime where it was (she self-manages config/profiles/ +# memories, so a host-side path in this file never reached her) — she +# searched /opt/data/homelab and /workspace, found neither, and +# concluded she had no repo at all. She can clone one herself if she +# wants; the credentials below are what actually grants the access. +# 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 @@ -79,15 +85,16 @@ let 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"; + # luna's gitea identity (account + PR-tier repo access provisioned in + # services/dev/gitea.nix). Only the server is pinned here — any checkout + # is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data. giteaHost = "git.mgaction.town"; - giteaRepo = "darman/homelab"; + + # hermesHome as the CONTAINER sees it (the bind mount below). Anything + # written host-side that gets READ back inside the container must use this + # prefix, not hermesHome — see the credential.helper below, which was + # broken exactly that way from 3c1f3e5 until 2026-08-23. + containerHome = "/opt/data"; in { # Browsing convenience (ssh access to the bind-mounted local state) — does @@ -113,11 +120,16 @@ in # # 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). + # /opt/data/... once the container is up). Both run on the HOST as root, + # before the container starts, and both therefore have to chown what they + # write themselves — see the chown at the end of the script. Do NOT assume + # the image's cont-init fixes ownership under hermesHome: it does not + # recurse into what this oneshot drops there, even though it runs after it. + # + # It deliberately does NOT clone the repo for her any more (see the + # header). The stale ${hermesHome}/workspace/homelab left behind by the + # version that did is not cleaned up here either — it just stops being + # managed, and stops being updated. Remove it by hand if you want it gone. # # 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 @@ -135,30 +147,52 @@ in 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 + # Never embed the token in a remote URL (it would land in that + # clone's .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" + # containerHome, NOT hermesHome: git reads this .gitconfig from INSIDE + # the container, where the host path does not exist. Nothing host-side + # consumes these credentials any more (the clone that used to is gone), + # so the container's view is the only one that has to be right. + git config --global credential.helper "store --file=${containerHome}/.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 + + # Hand everything written above to the container's uid/gid. This does + # NOT happen by itself: the image's cont-init only chowns hermesHome's + # top level and its own state, so root-owned 0600 files dropped here by + # this oneshot (.git-credentials, and tea's config.yml — tea writes it + # 0600 too) are simply unreadable to uid ${hermesUid}. Symptom is not an + # error but an absence: git reports no credential helper and tea reports + # no login, i.e. "they're missing". Confirmed on the real instance + # 2026-08-23 — cont-init ran AFTER these files were written and left + # them root-owned regardless. + # + # `if`, not `[ -d x ] && chown`: this script runs under `set -e`, where + # a false test as the left side of an && list takes the whole list's + # non-zero status and aborts the unit. + chown ${hermesUid}:${hermesGid} \ + ${hermesHome}/.gitconfig \ + ${hermesHome}/.git-credentials + if [ -d ${hermesHome}/.config ]; then + chown ${hermesUid}:${hermesGid} ${hermesHome}/.config + fi + if [ -d ${hermesHome}/.config/tea ]; then + chown -R ${hermesUid}:${hermesGid} ${hermesHome}/.config/tea + fi ''; }; diff --git a/services/dev/gitea.nix b/services/dev/gitea.nix index 7f1be92..cb914c9 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -220,19 +220,31 @@ in # - 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. + # 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 (used by whatever git tooling gets wired into - # hermes-agent.nix later) is generated once, the same way ci-bot's was: + # 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' + # --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" ];