diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1b3d2892..47b1e303 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -159,14 +159,22 @@ if [ -n "$STAGED_NIX" ]; then log_success "Statix passed! No antipatterns found." fi - log_info "Formatting staged .nix files with alejandra..." - echo "$STAGED_NIX" | while read -r f; do - if [ -f "$f" ]; then - nix shell nixpkgs#alejandra --command alejandra "$f" 2>/dev/null || true + 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." + all_passed=false + else + echo "$STAGED_NIX" | while read -r f; do git add "$f" 2>/dev/null || true - fi - done - log_success "Staged .nix files formatted." + done + log_success "Staged .nix files formatted." + fi else log_info "No staged .nix files — skipping Nix linters." fi diff --git a/docs/status/2026-08-19_17-36_rag-embedding-reranker-architecture-decision.md b/docs/status/2026-08-19_17-36_rag-embedding-reranker-architecture-decision.md index 00c74159..bc9ff390 100644 --- a/docs/status/2026-08-19_17-36_rag-embedding-reranker-architecture-decision.md +++ b/docs/status/2026-08-19_17-36_rag-embedding-reranker-architecture-decision.md @@ -1,6 +1,6 @@ # Status Report: RAG Embedding + Reranker Architecture Decision -**Date:** 2026-08-19 17:36 +**Date:** 2026-08-19 17:36 **Session focus:** Evaluating whether to add HuggingFace Text Embeddings Inference (TEI) permanently, and finding the best reranker-capable serving stack for the homelab --- diff --git a/docs/status/2026-08-19_17-49_systemd-graph-package-fix-partial-deploy-https-broken.md b/docs/status/2026-08-19_17-49_systemd-graph-package-fix-partial-deploy-https-broken.md index 5607d8f1..ba8e31f1 100644 --- a/docs/status/2026-08-19_17-49_systemd-graph-package-fix-partial-deploy-https-broken.md +++ b/docs/status/2026-08-19_17-49_systemd-graph-package-fix-partial-deploy-https-broken.md @@ -58,7 +58,7 @@ Fixed all three systemd-graph package build blockers (pnpm hook, vendorHash, bin ### 1. The Root Cause of the Webui Build Failure (Previous Session) -**The bug was `dontConfigure = true`.** The previous session added `pnpmConfigHook` to `nativeBuildInputs` (correct) but then set `dontConfigure = true` (wrong) and hand-rolled `pnpm install` in `buildPhase` (fighting the hook). +**The bug was `dontConfigure = true`.** The previous session added `pnpmConfigHook` to `nativeBuildInputs` (correct) but then set `dontConfigure = true` (wrong) and hand-rolled `pnpm install` in `buildPhase` (fighting the hook). `pnpmConfigHook` is registered in `postConfigureHooks` — it fires during the **configure** phase, not the build phase. Setting `dontConfigure = true` skips the entire configure phase, so `pnpmConfigHook` **never ran**. The hand-rolled `pnpm install` in buildPhase then tried to install deps manually, but: diff --git a/modules/nixos/services/_forgejo-scripts.nix b/modules/nixos/services/_forgejo-scripts.nix index 83c3305b..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"; @@ -307,6 +313,132 @@ ''; }; + # 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 + ]; + text = '' + # Idempotent: create hermes-agent user (unprivileged, no UI login needed), + # 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 + # 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} + # 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 + + # 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 -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). + # Match by EMAIL: forgejo enforces unique emails, and the username is + # a substring of it (plain username grep would false-positive). + 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" \ + --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. + # 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 "$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" + exit 0 + fi + echo "Existing token invalid; regenerating" + fi + + TOKEN=$("$FORGEJO" admin user generate-access-token \ + --username "$FORGEJO_USER_NAME" \ + --token-name "hermes-agent-$(date +%s)" \ + --scopes read:repository \ + --raw) || TOKEN="" + + if ! echo "$TOKEN" | grep -qE '^[0-9a-f]{40}$'; then + echo "ERROR: token generation failed for hermes-agent" >&2 + exit 1 + fi + + # 3. stage forgejo-only; ExecStartPost installs the hermes copy at + # /run/hermes-forgejo-token (0400 hermes:hermes, tmpfs) + # 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" + ''; + }; + + # 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). + # 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 ${hermesCfg.user or "hermes"} \ + -g ${hermesCfg.group or "hermes"} \ + -m 0400 \ + ${stateDir}/hermes-agent.token \ + /run/hermes-forgejo-token + ''; + }; + tokenGen = pkgs.writeShellApplication { name = "forgejo-token-gen"; runtimeInputs = [ diff --git a/modules/nixos/services/forgejo.nix b/modules/nixos/services/forgejo.nix index be39462c..260e1f86 100644 --- a/modules/nixos/services/forgejo.nix +++ b/modules/nixos/services/forgejo.nix @@ -60,6 +60,9 @@ _: { ensurePasswordFile adminSetup tokenGen + hermesForgejoToken + hermesForgejoTokenDeliver + hermesCfg genRunnerToken registerRunner oidcSetupScript @@ -266,7 +269,7 @@ _: { ReadWritePaths = [ forgejoBackupDir ]; }) (serviceOneshotDefaults { }) - (ioTier.background) + ioTier.background { Type = "oneshot"; User = "forgejo"; @@ -303,6 +306,53 @@ _: { }; }; + # --- 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. + # 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" + "forgejo-generate-token.service" + ]; + wants = [ "forgejo.service" ]; + wantedBy = [ "forgejo.service" ]; + startLimitBurst = 5; + startLimitIntervalSec = 300; + inherit onFailure; + restartTriggers = [ + (lib.getExe hermesForgejoToken) + (lib.getExe hermesForgejoTokenDeliver) + ]; + serviceConfig = lib.mkMerge [ + { + Type = "oneshot"; + # 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 — 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 { }) + (serviceOneshotDefaults { }) + ]; + script = lib.getExe hermesForgejoToken; + }; + systemd.services.forgejo-generate-token = { description = "Generate Forgejo API token"; after = [ "forgejo.service" ]; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5cc241c0..70da6ceb 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 bank-sync-storage-dir google-sync-dirs llama-rag-model-fetch; 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 bank-sync-storage-dir google-sync-dirs llama-rag-model-fetch; 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