From d279236be136e747176cd29f21ba20d471954e1b Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 19 Aug 2026 00:25:03 +0200 Subject: [PATCH 1/5] feat(forgejo): provision hermes-agent user + read-only token Dedicated Forgejo identity for the Hermes agent: normal user owning nothing, token scoped to read:repository, delivered to /run/hermes-forgejo-token (hermes:hermes 0400, tmpfs). NOT a restricted user: restricted users cannot browse other users' public repos, which would defeat the purpose. Least privilege is achieved via the token scope instead. Co-authored-by: Lars Artmann --- modules/nixos/services/_forgejo-scripts.nix | 74 +++++++++++++++++++++ modules/nixos/services/forgejo.nix | 25 +++++++ 2 files changed, 99 insertions(+) diff --git a/modules/nixos/services/_forgejo-scripts.nix b/modules/nixos/services/_forgejo-scripts.nix index 83c3305b..4cf2e3de 100644 --- a/modules/nixos/services/_forgejo-scripts.nix +++ b/modules/nixos/services/_forgejo-scripts.nix @@ -307,6 +307,80 @@ ''; }; + hermesForgejoToken = pkgs.writeShellApplication { + name = "forgejo-hermes-token"; + runtimeInputs = [ + pkgs.coreutils + pkgs.gnugrep + pkgs.curl + ]; + text = '' + # Idempotent: create hermes-agent user (unprivileged, no UI login needed), + # mint a read:repository-scoped token, deliver it hermes-readable. + # + # NOT --restricted: restricted users cannot see other users' PUBLIC repos, + # which would defeat the purpose. Least privilege here = normal user that + # owns nothing + token scoped to read:repository (sees exactly what an + # anonymous visitor sees, plus any private repo explicitly granted later). + set -euo pipefail + + FORGEJO=${lib.getExe forgejoPkg} + export FORGEJO_WORK_DIR=${stateDir} + TOKEN_FILE=/run/hermes-forgejo-token + FORGEJO_USER_NAME=hermes-agent + FORGEJO_USER_EMAIL=hermes-agent@noreply.forgejo.home.lan + + for _ in $(seq 1 30); do + curl -s -o /dev/null -w "" "${forgejoUrl}/" && break + sleep 1 + done + + # 1. user (create-or-verify; password is random and never delivered — + # the token is the only credential that leaves this box) + if ! runuser -u forgejo -- "$FORGEJO" admin user list 2>/dev/null | grep -q "$FORGEJO_USER_NAME"; then + echo "Creating Forgejo user: $FORGEJO_USER_NAME" + runuser -u forgejo -- "$FORGEJO" admin user create \ + --username "$FORGEJO_USER_NAME" \ + --email "$FORGEJO_USER_EMAIL" \ + --random-password \ + --must-change-password=false + else + echo "User $FORGEJO_USER_NAME already exists" + fi + + # 2. token — reuse if still valid, else mint a new one + TOKEN="" + if [ -s "$TOKEN_FILE" ]; then + TOKEN=$(cat "$TOKEN_FILE") + if curl -sf -H "Authorization: token $TOKEN" "${forgejoUrl}/api/v1/user" >/dev/null 2>&1; then + echo "Existing hermes-agent token still valid" + chown hermes:hermes "$TOKEN_FILE" + chmod 0400 "$TOKEN_FILE" + exit 0 + fi + echo "Existing token invalid; regenerating" + fi + + TOKEN=$(runuser -u forgejo -- "$FORGEJO" admin user generate-access-token \ + --username "$FORGEJO_USER_NAME" \ + --token-name "hermes-agent-$(date +%s)" \ + --scopes read:repository \ + --raw 2>/dev/null) || TOKEN="" + + if ! echo "$TOKEN" | grep -qE '^[0-9a-f]{40}$'; then + echo "ERROR: token generation failed for hermes-agent" >&2 + exit 1 + fi + + # 3. deliver: /run is tmpfs; 0400 hermes-owned = only the agent can read it + umask 377 + printf '%s' "$TOKEN" > "$TOKEN_FILE" + chown hermes:hermes "$TOKEN_FILE" + chmod 0400 "$TOKEN_FILE" + echo "hermes-agent token delivered to $TOKEN_FILE" + ''; + }; + tokenGen = pkgs.writeShellApplication { name = "forgejo-token-gen"; runtimeInputs = [ diff --git a/modules/nixos/services/forgejo.nix b/modules/nixos/services/forgejo.nix index be39462c..c02bedb8 100644 --- a/modules/nixos/services/forgejo.nix +++ b/modules/nixos/services/forgejo.nix @@ -60,6 +60,7 @@ _: { ensurePasswordFile adminSetup tokenGen + hermesForgejoToken genRunnerToken registerRunner oidcSetupScript @@ -303,6 +304,30 @@ _: { }; }; + # --- Hermes Agent read-only access (added 2026-08-19, PR: forgejo-hermes-agent) --- + systemd.services.forgejo-hermes-token = { + description = "Provision hermes-agent Forgejo user + read-only token"; + after = [ + "forgejo.service" + "forgejo-generate-token.service" + ]; + wants = [ "forgejo-generate-token.service" ]; + wantedBy = [ "forgejo.service" ]; + restartTriggers = [ (lib.getExe hermesForgejoToken) ]; + serviceConfig = lib.mkMerge [ + { + Type = "oneshot"; + User = "forgejo"; + Group = "forgejo"; + # Script runs forgejo CLI as forgejo user (no root); the only + # cross-user action is chown of the token file to hermes. + RemainAfterExit = true; + } + (harden { }) + ]; + script = lib.getExe hermesForgejoToken; + }; + systemd.services.forgejo-generate-token = { description = "Generate Forgejo API token"; after = [ "forgejo.service" ]; From eca065d9c1ead83b158235dbfbb7cdb839d57b9f Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 19 Aug 2026 01:04:07 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(forgejo):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20root+caps=20provisioning,=20fail-fast=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: service ran as forgejo:forgejo which cannot create /run/hermes-forgejo-token, chown it to hermes, or reread the 0400 file; harden {} strips all capabilities. Now a root oneshot with explicit CapabilityBoundingSet (buildcache idiom): CHOWN/FOWNER/DAC_OVERRIDE for the token file lifecycle, SETUID/SETGID for runuser. util-linux added to runtimeInputs for runuser. Major: readiness loop accepted HTTP errors and had no timeouts; could 'succeed' against a dead Forgejo. Now --fail + --connect-timeout 3 --max-time 5, exits nonzero after 30 failed attempts; validation curl bounded likewise; TimeoutStartSec=4min covers the full retry budget. --- modules/nixos/services/_forgejo-scripts.nix | 11 +++++++++-- modules/nixos/services/forgejo.nix | 19 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/modules/nixos/services/_forgejo-scripts.nix b/modules/nixos/services/_forgejo-scripts.nix index 4cf2e3de..185697f1 100644 --- a/modules/nixos/services/_forgejo-scripts.nix +++ b/modules/nixos/services/_forgejo-scripts.nix @@ -313,6 +313,7 @@ pkgs.coreutils pkgs.gnugrep pkgs.curl + pkgs.util-linux # runuser (service runs as root; CLI runs as forgejo) ]; text = '' # Idempotent: create hermes-agent user (unprivileged, no UI login needed), @@ -330,10 +331,16 @@ FORGEJO_USER_NAME=hermes-agent FORGEJO_USER_EMAIL=hermes-agent@noreply.forgejo.home.lan + # Fail fast if Forgejo never comes up: --fail treats HTTP errors as errors, + # bounded connect/total timeouts prevent a hung curl per iteration. for _ in $(seq 1 30); do - curl -s -o /dev/null -w "" "${forgejoUrl}/" && break + curl -sf --connect-timeout 3 --max-time 5 -o /dev/null "${forgejoUrl}/" && break sleep 1 done + curl -sf --connect-timeout 3 --max-time 5 -o /dev/null "${forgejoUrl}/" || { + echo "ERROR: Forgejo not reachable at ${forgejoUrl} after 30 attempts" >&2 + exit 1 + } # 1. user (create-or-verify; password is random and never delivered — # the token is the only credential that leaves this box) @@ -352,7 +359,7 @@ TOKEN="" if [ -s "$TOKEN_FILE" ]; then TOKEN=$(cat "$TOKEN_FILE") - if curl -sf -H "Authorization: token $TOKEN" "${forgejoUrl}/api/v1/user" >/dev/null 2>&1; then + if curl -sf --connect-timeout 3 --max-time 10 -H "Authorization: token $TOKEN" "${forgejoUrl}/api/v1/user" >/dev/null 2>&1; then echo "Existing hermes-agent token still valid" chown hermes:hermes "$TOKEN_FILE" chmod 0400 "$TOKEN_FILE" diff --git a/modules/nixos/services/forgejo.nix b/modules/nixos/services/forgejo.nix index c02bedb8..a6477c2f 100644 --- a/modules/nixos/services/forgejo.nix +++ b/modules/nixos/services/forgejo.nix @@ -317,13 +317,22 @@ _: { serviceConfig = lib.mkMerge [ { Type = "oneshot"; - User = "forgejo"; - Group = "forgejo"; - # Script runs forgejo CLI as forgejo user (no root); the only - # cross-user action is chown of the token file to hermes. + # Root oneshot (buildcache idiom): must create /run/hermes-forgejo-token, + # chown it hermes:hermes, reread the 0400 file on rerun, and runuser → + # forgejo for the CLI. harden {} empties the capability bounding set, + # so the needed caps are re-added explicitly: + # CAP_CHOWN/CAP_FOWNER/CAP_DAC_OVERRIDE — token file lifecycle + # CAP_SETUID/CAP_SETGID — runuser to the forgejo user + User = "root"; + Group = "root"; + # 30 readiness tries × (curl --max-time 5 + sleep 1) + CLI ops ≈ 3min budget + TimeoutStartSec = "4min"; RemainAfterExit = true; } - (harden { }) + (harden { + CapabilityBoundingSet = "CAP_CHOWN CAP_FOWNER CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID"; + ReadWritePaths = [ "/run" ]; + }) ]; script = lib.getExe hermesForgejoToken; }; From e4a0634affa3ae8fd42704933917b809725844bc Mon Sep 17 00:00:00 2001 From: LarsArtmann Date: Wed, 19 Aug 2026 04:45:52 +0200 Subject: [PATCH 3/5] fix(forgejo): make hermes-agent token provisioning actually runnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root+runuser mechanism could never complete a run: runuser cannot open a PAM session inside harden {}, the exact failure reverted in 118e75f2 after a 2-day crash loop, so every run died before minting a token. The validity probe also hit GET /api/v1/user, which 403s for a read:repository-only token, and the token lived only in tmpfs, so a fresh orphaned valid token would have been minted every boot. - Unit runs as the forgejo user directly (tokenGen idiom), zero capabilities; delivery moved to a "+"-prefixed ExecStartPost (gitea-runner +forgejo-gen-runner-token idiom) that installs the staged token as hermes:hermes 0400 into /run. - Token persisted forgejo-only in the forgejo state dir so the reuse path survives reboots; probe switched to GET /api/v1/repos/search, the repository scope category (verified against forgejo 15.0.6 sources: /user and /user/repos both require the user scope category; live-confirmed revoked tokens are 401'd by the auth middleware before routing). - Unit gated on services.hermes.enable (hermes user coupling), start limits and onFailure alerting added, and the unit added to deploy.sh provisioner restarts since restartTriggers are ignored for oneshot+RemainAfterExit units. Verified: nix flake check --no-build, merged unit eval (User=forgejo, no caps, +ExecStartPost, burst 5/300, onFailure), negative eval (hermes disabled removes the unit), both scripts build through writeShellApplication's shellcheck gate, repo formatter clean. 💘 Generated with Crush Assisted-by: Crush:glm-5.3 --- modules/nixos/services/_forgejo-scripts.nix | 71 +++++++++++++++------ modules/nixos/services/forgejo.nix | 47 ++++++++------ scripts/deploy.sh | 2 +- 3 files changed, 80 insertions(+), 40 deletions(-) diff --git a/modules/nixos/services/_forgejo-scripts.nix b/modules/nixos/services/_forgejo-scripts.nix index 185697f1..9b2f3fd7 100644 --- a/modules/nixos/services/_forgejo-scripts.nix +++ b/modules/nixos/services/_forgejo-scripts.nix @@ -307,17 +307,20 @@ ''; }; + # Runs AS the forgejo user (tokenGen idiom): the CLI talks to the DB + # directly, no runuser/PAM needed (runuser cannot init a PAM session inside + # harden {}, documented gotcha). The staged token is delivered to /run by + # hermesForgejoTokenDeliver via the unit's "+"-prefixed ExecStartPost. hermesForgejoToken = pkgs.writeShellApplication { name = "forgejo-hermes-token"; runtimeInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.curl - pkgs.util-linux # runuser (service runs as root; CLI runs as forgejo) ]; text = '' # Idempotent: create hermes-agent user (unprivileged, no UI login needed), - # mint a read:repository-scoped token, deliver it hermes-readable. + # mint a read:repository-scoped token, stage it for hermes delivery. # # NOT --restricted: restricted users cannot see other users' PUBLIC repos, # which would defeat the purpose. Least privilege here = normal user that @@ -327,7 +330,10 @@ FORGEJO=${lib.getExe forgejoPkg} export FORGEJO_WORK_DIR=${stateDir} - TOKEN_FILE=/run/hermes-forgejo-token + # Persisted forgejo-only staging file: survives reboots so the reuse path + # works and tokens do not accumulate. The /run copy is (re)installed by + # ExecStartPost on every run. + STAGED_TOKEN_FILE=${stateDir}/hermes-agent.token FORGEJO_USER_NAME=hermes-agent FORGEJO_USER_EMAIL=hermes-agent@noreply.forgejo.home.lan @@ -343,10 +349,12 @@ } # 1. user (create-or-verify; password is random and never delivered — - # the token is the only credential that leaves this box) - if ! runuser -u forgejo -- "$FORGEJO" admin user list 2>/dev/null | grep -q "$FORGEJO_USER_NAME"; then + # the token is the only credential that leaves this box). + # Match by EMAIL: forgejo enforces unique emails, and the username is + # a substring of it (plain username grep would false-positive). + if ! "$FORGEJO" admin user list 2>/dev/null | grep -q "$FORGEJO_USER_EMAIL"; then echo "Creating Forgejo user: $FORGEJO_USER_NAME" - runuser -u forgejo -- "$FORGEJO" admin user create \ + "$FORGEJO" admin user create \ --username "$FORGEJO_USER_NAME" \ --email "$FORGEJO_USER_EMAIL" \ --random-password \ @@ -355,20 +363,28 @@ echo "User $FORGEJO_USER_NAME already exists" fi - # 2. token — reuse if still valid, else mint a new one + # 2. token — reuse if still valid, else mint a new one. + # The validity probe MUST stay in the repository scope category: + # GET /api/v1/user requires the "user" scope (403 for a + # read:repository-only token), and GET /api/v1/user/repos requires + # BOTH user and repository categories (group middleware composes + # AND-style; verified against forgejo 15.0.6 routers/api/v1/api.go + + # modules/web/route.go). GET /api/v1/repos/search sits in the + # repository-scoped group only: 200 for this token, 401 once revoked + # (invalid tokens are rejected by the auth middleware before routing). TOKEN="" - if [ -s "$TOKEN_FILE" ]; then - TOKEN=$(cat "$TOKEN_FILE") - if curl -sf --connect-timeout 3 --max-time 10 -H "Authorization: token $TOKEN" "${forgejoUrl}/api/v1/user" >/dev/null 2>&1; then + if [ -s "$STAGED_TOKEN_FILE" ]; then + TOKEN=$(cat "$STAGED_TOKEN_FILE") + if curl -sf --connect-timeout 3 --max-time 10 \ + -H "Authorization: token $TOKEN" \ + "${forgejoUrl}/api/v1/repos/search?limit=1" >/dev/null 2>&1; then echo "Existing hermes-agent token still valid" - chown hermes:hermes "$TOKEN_FILE" - chmod 0400 "$TOKEN_FILE" exit 0 fi echo "Existing token invalid; regenerating" fi - TOKEN=$(runuser -u forgejo -- "$FORGEJO" admin user generate-access-token \ + TOKEN=$("$FORGEJO" admin user generate-access-token \ --username "$FORGEJO_USER_NAME" \ --token-name "hermes-agent-$(date +%s)" \ --scopes read:repository \ @@ -379,12 +395,29 @@ exit 1 fi - # 3. deliver: /run is tmpfs; 0400 hermes-owned = only the agent can read it - umask 377 - printf '%s' "$TOKEN" > "$TOKEN_FILE" - chown hermes:hermes "$TOKEN_FILE" - chmod 0400 "$TOKEN_FILE" - echo "hermes-agent token delivered to $TOKEN_FILE" + # 3. stage forgejo-only; ExecStartPost installs the hermes copy at + # /run/hermes-forgejo-token (0400 hermes:hermes, tmpfs) + printf '%s' "$TOKEN" > "$STAGED_TOKEN_FILE" + chmod 0400 "$STAGED_TOKEN_FILE" + echo "hermes-agent token staged at $STAGED_TOKEN_FILE" + ''; + }; + + # Installed by forgejo-hermes-token's "+"-prefixed ExecStartPost: runs with + # FULL privileges (outside harden {}), where chown to the hermes user works + # without capabilities on the sandboxed main process (gitea-runner's + # +forgejo-gen-runner-token idiom). + hermesForgejoTokenDeliver = pkgs.writeShellApplication { + name = "forgejo-hermes-token-deliver"; + runtimeInputs = [ pkgs.coreutils ]; + text = '' + set -euo pipefail + install \ + -o ${config.services.hermes.user} \ + -g ${config.services.hermes.group} \ + -m 0400 \ + ${stateDir}/hermes-agent.token \ + /run/hermes-forgejo-token ''; }; diff --git a/modules/nixos/services/forgejo.nix b/modules/nixos/services/forgejo.nix index a6477c2f..256b070c 100644 --- a/modules/nixos/services/forgejo.nix +++ b/modules/nixos/services/forgejo.nix @@ -61,6 +61,7 @@ _: { adminSetup tokenGen hermesForgejoToken + hermesForgejoTokenDeliver genRunnerToken registerRunner oidcSetupScript @@ -267,7 +268,7 @@ _: { ReadWritePaths = [ forgejoBackupDir ]; }) (serviceOneshotDefaults { }) - (ioTier.background) + ioTier.background { Type = "oneshot"; User = "forgejo"; @@ -305,34 +306,40 @@ _: { }; # --- Hermes Agent read-only access (added 2026-08-19, PR: forgejo-hermes-agent) --- - systemd.services.forgejo-hermes-token = { + # mkIf hermes: the token is chown'd to the hermes user in ExecStartPost, + # which only exists when the hermes service is enabled. + systemd.services.forgejo-hermes-token = lib.mkIf config.services.hermes.enable { description = "Provision hermes-agent Forgejo user + read-only token"; - after = [ - "forgejo.service" - "forgejo-generate-token.service" - ]; - wants = [ "forgejo-generate-token.service" ]; + after = [ "forgejo.service" ]; + wants = [ "forgejo.service" ]; wantedBy = [ "forgejo.service" ]; - restartTriggers = [ (lib.getExe hermesForgejoToken) ]; + startLimitBurst = 5; + startLimitIntervalSec = 300; + inherit onFailure; + restartTriggers = [ + (lib.getExe hermesForgejoToken) + (lib.getExe hermesForgejoTokenDeliver) + ]; serviceConfig = lib.mkMerge [ { Type = "oneshot"; - # Root oneshot (buildcache idiom): must create /run/hermes-forgejo-token, - # chown it hermes:hermes, reread the 0400 file on rerun, and runuser → - # forgejo for the CLI. harden {} empties the capability bounding set, - # so the needed caps are re-added explicitly: - # CAP_CHOWN/CAP_FOWNER/CAP_DAC_OVERRIDE — token file lifecycle - # CAP_SETUID/CAP_SETGID — runuser to the forgejo user - User = "root"; - Group = "root"; + # forgejo-user idiom (tokenGen): CLI runs directly, no runuser — + # PAM cannot open a session inside harden {} (documented gotcha, + # 2026-07-17 forgejo-oidc-setup incident). The only root step is + # the delivery below. + User = "forgejo"; + Group = "forgejo"; # 30 readiness tries × (curl --max-time 5 + sleep 1) + CLI ops ≈ 3min budget TimeoutStartSec = "4min"; RemainAfterExit = true; + # "+" = full-privilege escape hatch (gitea-runner's + # +forgejo-gen-runner-token idiom): installs the staged token as + # hermes:hermes 0400 into /run. Runs after ExecStart on every + # successful start, so the tmpfs copy is refreshed each boot. + ExecStartPost = [ ("+" + lib.getExe hermesForgejoTokenDeliver) ]; } - (harden { - CapabilityBoundingSet = "CAP_CHOWN CAP_FOWNER CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID"; - ReadWritePaths = [ "/run" ]; - }) + (harden { }) + (serviceOneshotDefaults { }) ]; script = lib.getExe hermesForgejoToken; }; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f450a76c..50d5b6d3 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -142,7 +142,7 @@ if nix run .#pre-deploy-check; then # after their first run. switch-to-configuration does NOT restart them even # when restartTriggers change. This means provisioning fixes deployed to the # Nix store never re-run without an explicit restart. - for provisioner in signoz-provision pocket-id-provision browser-history-oidc-setup forgejo-generate-token forgejo-oidc-setup forgejo-ssh-keys twenty-fix-collation dnsblockd-attach-ip monitor365-schema-migrate atticd-storage-dir google-sync-dirs; do + for provisioner in signoz-provision pocket-id-provision browser-history-oidc-setup forgejo-generate-token forgejo-oidc-setup forgejo-ssh-keys forgejo-hermes-token twenty-fix-collation dnsblockd-attach-ip monitor365-schema-migrate atticd-storage-dir google-sync-dirs; do if systemctl is-enabled --quiet "$provisioner.service" 2>/dev/null; then echo "Restarting provisioner: $provisioner.service" sudo systemctl restart "$provisioner.service" 2>/dev/null || true From 8302b94b2964b9c73586d512515aceebbb1ed7c2 Mon Sep 17 00:00:00 2001 From: LarsArtmann Date: Wed, 19 Aug 2026 05:33:28 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(forgejo):=20address=20PR=20#139=20revie?= =?UTF-8?q?w=20=E2=80=94=20atomic=20token=20write,=20error=20visibility,?= =?UTF-8?q?=20hermes=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Atomic staged-token write via mktemp+install: the 0400 file was read-only even for the forgejo owner, so regeneration EACCES'd on every subsequent run - Capture admin user-list output and fail explicitly instead of suppressing stderr (2>/dev/null hid locked-DB / migration failures) - Remove 2>/dev/null from generate-access-token so Forgejo diagnostics reach the journal on failure - Guard all config.services.hermes references with 'or {}' fallback so a standalone nixosModules.forgejo consumer without nixosModules.hermes evaluates without error - Add forgejo-generate-token.service to after= to prevent concurrent CLI writes against the same DB (SQLite database-is-locked) - Tighten ExecStartPost comment: refresh happens on boot and explicit restart only, NOT on plain forgejo.service restarts (RemainAfterExit keeps unit active) 💘 Generated with Crush Assisted-by: Crush:hf:zai-org/GLM-5.2 --- .githooks/pre-commit | 16 +++++++---- modules/nixos/services/_forgejo-scripts.nix | 30 ++++++++++++++++----- modules/nixos/services/forgejo.nix | 15 ++++++++--- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1b3d2892..1c6c4330 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -159,12 +159,18 @@ if [ -n "$STAGED_NIX" ]; then log_success "Statix passed! No antipatterns found." fi - log_info "Formatting staged .nix files with alejandra..." + log_info "Formatting staged .nix files with the pinned formatter (nix fmt)..." + # MUST be the flake-pinned formatter (nix fmt = treefmt-full-flake from + # flake.lock), NOT unpinned `nixpkgs#alejandra`: the registry floats to + # nixos-unstable whose alejandra style diverges from the pinned one and + # injects whole-file reformats that the repo formatter (and CI's + # `nix fmt -- --ci`) immediately revert — a formatter split-brain that + # ships churn in commits (hit on PR #139, 2026-08-19). + if ! echo "$STAGED_NIX" | xargs nix fmt --; then + log_error "nix fmt failed on staged files — leaving them unformatted." + fi echo "$STAGED_NIX" | while read -r f; do - if [ -f "$f" ]; then - nix shell nixpkgs#alejandra --command alejandra "$f" 2>/dev/null || true - git add "$f" 2>/dev/null || true - fi + git add "$f" 2>/dev/null || true done log_success "Staged .nix files formatted." else diff --git a/modules/nixos/services/_forgejo-scripts.nix b/modules/nixos/services/_forgejo-scripts.nix index 9b2f3fd7..3d12d984 100644 --- a/modules/nixos/services/_forgejo-scripts.nix +++ b/modules/nixos/services/_forgejo-scripts.nix @@ -13,6 +13,12 @@ runnerLabels, runnerConfigFile, }: +let + # 'or {}' so a standalone nixosModules.forgejo consumer that does not import + # nixosModules.hermes evaluates without error (the deliver script and unit + # are only wired when hermes is enabled — see forgejo.nix). + hermesCfg = config.services.hermes or { }; +in { mirrorGithubScript = pkgs.writeShellApplication { name = "forgejo-mirror-github"; @@ -352,7 +358,11 @@ # the token is the only credential that leaves this box). # Match by EMAIL: forgejo enforces unique emails, and the username is # a substring of it (plain username grep would false-positive). - if ! "$FORGEJO" admin user list 2>/dev/null | grep -q "$FORGEJO_USER_EMAIL"; then + USER_LIST=$("$FORGEJO" admin user list) || { + echo "ERROR: forgejo admin user list failed" >&2 + exit 1 + } + if ! printf '%s' "$USER_LIST" | grep -q "$FORGEJO_USER_EMAIL"; then echo "Creating Forgejo user: $FORGEJO_USER_NAME" "$FORGEJO" admin user create \ --username "$FORGEJO_USER_NAME" \ @@ -388,7 +398,7 @@ --username "$FORGEJO_USER_NAME" \ --token-name "hermes-agent-$(date +%s)" \ --scopes read:repository \ - --raw 2>/dev/null) || TOKEN="" + --raw) || TOKEN="" if ! echo "$TOKEN" | grep -qE '^[0-9a-f]{40}$'; then echo "ERROR: token generation failed for hermes-agent" >&2 @@ -397,8 +407,13 @@ # 3. stage forgejo-only; ExecStartPost installs the hermes copy at # /run/hermes-forgejo-token (0400 hermes:hermes, tmpfs) - printf '%s' "$TOKEN" > "$STAGED_TOKEN_FILE" - chmod 0400 "$STAGED_TOKEN_FILE" + # Atomic install: the existing 0400 file is read-only even for the + # forgejo owner, so a bare redirect would EACCES on regeneration. + TMP_TOKEN_FILE=$(mktemp "$STAGED_TOKEN_FILE.XXXXXX") + trap 'rm -f "$TMP_TOKEN_FILE"' EXIT + printf '%s' "$TOKEN" > "$TMP_TOKEN_FILE" + install -m 0400 "$TMP_TOKEN_FILE" "$STAGED_TOKEN_FILE" + rm -f "$TMP_TOKEN_FILE" echo "hermes-agent token staged at $STAGED_TOKEN_FILE" ''; }; @@ -407,14 +422,17 @@ # FULL privileges (outside harden {}), where chown to the hermes user works # without capabilities on the sandboxed main process (gitea-runner's # +forgejo-gen-runner-token idiom). + # hermesCfg (defined in the let binding above) falls back to {} when the + # hermes module is absent, so this script still builds for standalone forgejo. + inherit hermesCfg; hermesForgejoTokenDeliver = pkgs.writeShellApplication { name = "forgejo-hermes-token-deliver"; runtimeInputs = [ pkgs.coreutils ]; text = '' set -euo pipefail install \ - -o ${config.services.hermes.user} \ - -g ${config.services.hermes.group} \ + -o ${hermesCfg.user or "hermes"} \ + -g ${hermesCfg.group or "hermes"} \ -m 0400 \ ${stateDir}/hermes-agent.token \ /run/hermes-forgejo-token diff --git a/modules/nixos/services/forgejo.nix b/modules/nixos/services/forgejo.nix index 256b070c..260e1f86 100644 --- a/modules/nixos/services/forgejo.nix +++ b/modules/nixos/services/forgejo.nix @@ -62,6 +62,7 @@ _: { tokenGen hermesForgejoToken hermesForgejoTokenDeliver + hermesCfg genRunnerToken registerRunner oidcSetupScript @@ -308,9 +309,14 @@ _: { # --- Hermes Agent read-only access (added 2026-08-19, PR: forgejo-hermes-agent) --- # mkIf hermes: the token is chown'd to the hermes user in ExecStartPost, # which only exists when the hermes service is enabled. - systemd.services.forgejo-hermes-token = lib.mkIf config.services.hermes.enable { + # hermesCfg (from _forgejo-scripts.nix) uses 'or {}' so a standalone + # nixosModules.forgejo consumer without nixosModules.hermes evaluates cleanly. + systemd.services.forgejo-hermes-token = lib.mkIf (hermesCfg.enable or false) { description = "Provision hermes-agent Forgejo user + read-only token"; - after = [ "forgejo.service" ]; + after = [ + "forgejo.service" + "forgejo-generate-token.service" + ]; wants = [ "forgejo.service" ]; wantedBy = [ "forgejo.service" ]; startLimitBurst = 5; @@ -335,7 +341,10 @@ _: { # "+" = full-privilege escape hatch (gitea-runner's # +forgejo-gen-runner-token idiom): installs the staged token as # hermes:hermes 0400 into /run. Runs after ExecStart on every - # successful start, so the tmpfs copy is refreshed each boot. + # successful start — i.e. on boot and on explicit restart of this + # unit (deploy.sh restarts it post-switch). It does NOT rerun on a + # plain forgejo.service restart because RemainAfterExit keeps this + # unit active and wantedBy skips already-active units. ExecStartPost = [ ("+" + lib.getExe hermesForgejoTokenDeliver) ]; } (harden { }) From 325e82de9143cdd738c883d788fe89d19c876acf Mon Sep 17 00:00:00 2001 From: LarsArtmann Date: Thu, 20 Aug 2026 09:34:31 +0200 Subject: [PATCH 5/5] fix(hooks): fail the commit when nix fmt errors on staged files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatting step logged an error on treefmt failure but still printed "Staged .nix files formatted" and never set all_passed=false, so a broken formatter run committed silently-unformatted files. Now a formatter failure aborts the commit like the other lint gates, and the success path (git add + log_success) only runs when nix fmt actually succeeded. 💘 Generated with Crush Assisted-by: Crush:glm-5.3 --- .githooks/pre-commit | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1c6c4330..47b1e303 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -168,11 +168,13 @@ if [ -n "$STAGED_NIX" ]; then # ships churn in commits (hit on PR #139, 2026-08-19). if ! echo "$STAGED_NIX" | xargs nix fmt --; then log_error "nix fmt failed on staged files — leaving them unformatted." + all_passed=false + else + echo "$STAGED_NIX" | while read -r f; do + git add "$f" 2>/dev/null || true + done + log_success "Staged .nix files formatted." fi - echo "$STAGED_NIX" | while read -r f; do - git add "$f" 2>/dev/null || true - done - log_success "Staged .nix files formatted." else log_info "No staged .nix files — skipping Nix linters." fi