-
Notifications
You must be signed in to change notification settings - Fork 0
feat(forgejo): hermes-agent read-only user + token provisioning #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d279236
eca065d
e4a0634
7ba8df4
7c03635
8302b94
8e79cfa
325e82d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+173
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Fail the hook when restaging fails.
Proposed fix- echo "$STAGED_NIX" | while read -r f; do
- git add "$f" 2>/dev/null || true
+ restage_failed=false
+ while IFS= read -r f; do
+ if ! git add "$f"; then
+ log_error "Failed to re-stage $f."
+ restage_failed=true
+ fi
done
- log_success "Staged .nix files formatted."
+ if [[ "$restage_failed" == true ]]; then
+ all_passed=false
+ else
+ log_success "Staged .nix files formatted."
+ fi🤖 Prompt for AI Agents |
||
| log_success "Staged .nix files formatted." | ||
| fi | ||
| else | ||
| log_info "No staged .nix files — skipping Nix linters." | ||
| fi | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+361
to
+365
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: The Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '330,390p' modules/nixos/services/_forgejo-scripts.nix
printf '%s\n' '--- Forgejo references and version pins ---'
rg -n -i 'forgejo|FORGEJO_USER_(NAME|EMAIL)|admin user list' modules/nixos/services/_forgejo-scripts.nix flake.nix flake.lock 2>/dev/null | head -200
printf '%s\n' '--- repository files mentioning the script ---'
git ls-files | rg 'forgejo|Forgejo' | head -200Repository: LarsArtmann/SystemNix Length of output: 19240 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- package wiring ---'
sed -n '1,180p' modules/nixos/services/forgejo.nix
printf '%s\n' '--- token-generation unit wiring ---'
sed -n '390,450p' modules/nixos/services/_forgejo-scripts.nix
printf '%s\n' '--- lock entries related to Forgejo ---'
rg -n -i -C 3 'forgejo|forgejoPkg|forgejo-bin' flake.nix flake.lock modules/nixos | head -240
printf '%s\n' '--- relevant status notes ---'
sed -n '1,220p' docs/status/2026-08-19_04-14-pr139-forgejo-hermes-token-review.mdRepository: LarsArtmann/SystemNix Length of output: 25439 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- forgejo-lts declarations ---'
rg -n -i -C 5 'forgejo-lts|forgejo.*version|version.*forgejo|nixpkgs' --glob '*.nix' --glob 'flake.lock' . | head -300
printf '%s\n' '--- flake inputs ---'
sed -n '1,180p' flake.nix
printf '%s\n' '--- token script and unit declarations ---'
sed -n '285,430p' modules/nixos/services/_forgejo-scripts.nix
printf '%s\n' '--- status file ---'
sed -n '1,240p' docs/status/2026-08-19_04-14-pr139-forgejo-hermes-token-review.mdRepository: LarsArtmann/SystemNix Length of output: 24416 🌐 Web query:
💡 Result: For Forgejo 15.0.6, the built-in command-line interface (CLI) for managing users is primarily designed for human interaction and does not provide native machine-readable output formats like JSON or CSV [1][2]. Admin User List To list users via the command line, use the following command (typically run as the user running the Forgejo service, e.g., the 'git' user) [3]: forgejo admin user list This command outputs a human-readable table to standard output [1][2]. The Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import subprocess
email = "hermes-agent@noreply.forgejo.home.lan"
username = "hermes-agent"
cases = {
"username exists with different email": (
"ID\tUsername\tEmail\n1\thermes-agent\trestored@example.net\n"
),
"email belongs to another username": (
"ID\tUsername\tEmail\n2\tother-user\thermes-agent@noreply.forgejo.home.lan\n"
),
"both identities match": (
"ID\tUsername\tEmail\n3\thermes-agent\thermes-agent@noreply.forgejo.home.lan\n"
),
}
for name, user_list in cases.items():
found_by_email = subprocess.run(
["grep", "-q", email],
input=user_list,
text=True,
capture_output=True,
).returncode == 0
print(f"{name}: current_check={found_by_email}, token_username={username}")
PY
printf '%s\n' '--- Forgejo 15.0.6 admin user list source ---'
curl -fsSL \
https://raw.githubusercontent.com/forgejo/forgejo/v15.0.6/cmd/admin_user_list.go |
sed -n '1,220p'Repository: LarsArtmann/SystemNix Length of output: 495 🌐 Web query:
💡 Result: There is no single universal standard for a JSON user list, but the industry-standard approach for representing a collection of users in JSON is to use an array of objects. Each object in the array represents a single user and contains specific key-value pairs for attributes such as username and email. An example of a standard JSON structure for a user list is provided below: { "users": [ { "username": "jdoe", "email": "jdoe@example.com" }, { "username": "asmith", "email": "asmith@example.com" } ] } In administrative contexts, such as the Forgejo command-line interface, user listing commands are used to retrieve information about system accounts [1]. While specific CLI tools may output plain text, if you are designing a JSON-based API or configuration export, the structure above is considered best practice for readability and consistency. Citations: Use The check uses 🤖 Prompt for AI Agents |
||
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| # 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 | ||
| ''; | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| tokenGen = pkgs.writeShellApplication { | ||
| name = "forgejo-token-gen"; | ||
| runtimeInputs = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve staged path boundaries before invoking
nix fmt.Default
xargssplits on whitespace. A staged file such asmodules/foo bar.nixbecomes multiple arguments, so the hook can fail or format the wrong paths. Use a NUL-delimited path list withxargs -0, or use a loop that preserves each path.🤖 Prompt for AI Agents