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" ];