claude-forgejo-sandbox/modules/reforge.nix
Padreug df0fd9a9ba feat: extract reforge engine into a standalone consumable flake
The forgejo-sandbox / reforge harness, lifted out of the machine config
into a host-agnostic, generic engine anyone can consume with Nix.

Two layers:
- engine (this repo) — nixosModules.reforge stands up the sandbox forge,
  provisions role accounts + tokens, enforces branch protection, and puts
  the reforge-* CLI + forgejo-mcp on PATH. Carries no project specifics.
- run config — per-project manifest/charter/agenda/issues an adopter fills
  in; scaffold one with the `reforge` flake template.

Portability fixes vs the in-config version:
- forgejo-mcp resolved from $REFORGE_MCP_BIN or PATH, never a named host
  (kills the nixosConfigurations.omni hardcode).
- all instance data + paths parameterized via REFORGE_* env, baked into the
  reforge-scripts wrappers from module options (configDir, agentsDir,
  refsDir, org, port, tokenOwner, ...).
- option namespace neutral (reforge.* not omni.packs.*); settings policies
  carry no absolute /etc/nixos paths.
- role briefs + orchestrator playbook genericized: all project specifics
  point at the charter; refs corpus optional.

Validated: nix flake check (eval) + builds of forgejo-mcp, reforge-scripts,
and a module-eval check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:51:47 +02:00

383 lines
15 KiB
Nix

# reforge — local Forgejo instance + provisioned role accounts for a
# multi-agent dev-team simulation. See docs/architecture.md and
# docs/reforge.md.
#
# One Claude (or other agent) session per role (backend-dev, frontend-dev,
# security-lead, reviewer, ...), each driving its own Forgejo account via a
# scoped API token, with branch protection on every working repo requiring
# the security-lead's approval before anything merges to main. The point is
# review isolation: the security reviewer pulls the PR diff itself instead
# of being handed the implementer's rationale.
#
# STANDALONE-IMPORTABLE and DISABLED BY DEFAULT. Localhost-only by design:
# Forgejo binds 127.0.0.1 and the built-in SSH server stays off — every
# session talks HTTP with its token.
#
# This module OWNS `services.forgejo` on the host — it is a dedicated
# sandbox forge, not meant to coexist with another Forgejo instance on the
# same machine. HTTP defaults to :3030 (not :3000) so it does not squat the
# most common ad-hoc dev-server port.
#
# Provisioning is a oneshot that re-runs on every rebuild — every step is
# check-before-create, so it converges instead of failing on re-run. Tokens
# land in <stateDir>/tokens/<user>.token (0600, owned by cfg.tokenOwner) —
# the credential a role session gets is that one file, nothing else.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.reforge;
fcfg = config.services.forgejo;
stateDir = "/var/lib/forgejo-sandbox";
tokensDir = "${stateDir}/tokens";
forgejoMcp = cfg.forgejoMcpPackage;
# The reforge-* CLI wrappers, with this host's defaults baked in as
# REFORGE_* env so the operator just runs `reforge-seed`, `reforge-role
# <role>`, etc. Every default is overridable per-invocation by exporting
# the matching REFORGE_* variable.
reforgeScripts = pkgs.callPackage ../packages/reforge-scripts.nix {
inherit forgejoMcp;
forgeUrl = "http://localhost:${toString cfg.httpPort}";
org = cfg.org;
adminUser = cfg.adminUser;
inherit tokensDir stateDir;
configDir = toString cfg.configDir;
agentsDir = if cfg.agentsDir == null then null else toString cfg.agentsDir;
refsDir = if cfg.refsDir == null then null else toString cfg.refsDir;
};
# Branch protection payload: nobody pushes main directly; one approval
# required, and only approvals from requiredApprovers count toward it.
branchProtectionJson = builtins.toJSON {
branch_name = "main";
rule_name = "main";
enable_push = false;
required_approvals = 1;
enable_approvals_whitelist = true;
# Gitea/Forgejo API quirk: this field is SINGULAR (unlike
# push_whitelist_usernames) — the plural form is silently ignored,
# leaving an empty whitelist that no approval can ever satisfy.
approvals_whitelist_username = cfg.requiredApprovers;
block_on_rejected_reviews = true;
dismiss_stale_approvals = true;
};
provisionScript = pkgs.writeShellApplication {
name = "reforge-provision";
runtimeInputs = with pkgs; [
curl
jq
openssl
util-linux # setpriv
gawk
gnugrep
coreutils
];
text = ''
BASE="http://127.0.0.1:${toString cfg.httpPort}"
API="$BASE/api/v1"
TOKENS_DIR=${tokensDir}
RESP=$(mktemp)
trap 'rm -f "$RESP"' EXIT
# forgejo CLI, as the forgejo user (sqlite is owned by it)
as_forgejo() {
setpriv --reuid=${fcfg.user} --regid=${fcfg.group} --clear-groups \
env GITEA_WORK_DIR=${fcfg.stateDir} HOME=${fcfg.stateDir} \
${lib.getExe fcfg.package} --config ${fcfg.stateDir}/custom/conf/app.ini "$@"
}
# admin API helpers
api() { # method path [json-body] -> echoes HTTP code, body in $RESP
local method=$1 path=$2 data=''${3:-}
local args=(
-sS -o "$RESP" -w '%{http_code}'
-X "$method"
-H "Authorization: token $ADMIN_TOKEN"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "''${args[@]}" "$API$path"
}
ok() { case $1 in 200|201|204) return 0 ;; *) return 1 ;; esac }
must() { # code context
if ! ok "$1"; then
echo "reforge: $2 failed (HTTP $1):" >&2
cat "$RESP" >&2
exit 1
fi
}
# idempotent primitives
ensure_user() { # username [extra admin-user-create flags]
local u=$1
shift
if as_forgejo admin user list | awk 'NR>1 {print $2}' | grep -qx "$u"; then
return
fi
# Accounts are token-driven; the password is throwaway.
local pw
pw=$(openssl rand -hex 16)
as_forgejo admin user create --username "$u" --email "$u@sandbox.invalid" \
--password "$pw" --must-change-password=false "$@" >/dev/null
echo "created user $u"
}
ensure_token() { # username
local u=$1 f="$TOKENS_DIR/$1.token" tok
if [ -s "$f" ]; then return; fi
# Unique token name per generation: if the file was wiped but the
# server still holds an old token, a fixed name would collide.
tok=$(as_forgejo admin user generate-access-token --username "$u" \
--token-name "sandbox-$(date +%s%N)" --scopes all --raw)
(
umask 077
printf '%s\n' "$tok" >"$f"
)
chown ${cfg.tokenOwner}: "$f"
chmod 600 "$f"
echo "wrote token $f"
}
# wait for the API
ready=
for _ in $(seq 1 60); do
if curl -sf "$BASE/api/healthz" >/dev/null 2>&1; then
ready=1
break
fi
sleep 2
done
if [ "$ready" != 1 ]; then
echo "reforge: API at $BASE never became healthy" >&2
exit 1
fi
# users + tokens
ensure_user ${cfg.adminUser} --admin
ensure_token ${cfg.adminUser}
# UI login for the admin (role accounts stay token-only): the
# create-time password is throwaway, so persist a known one for
# the human to browse the web UI with. Set once, kept across runs.
PW_FILE=${stateDir}/admin-password
if [ ! -s "$PW_FILE" ]; then
pw=$(openssl rand -hex 12)
as_forgejo admin user change-password --username ${cfg.adminUser} \
--password "$pw" --must-change-password=false >/dev/null
(
umask 077
printf '%s\n' "$pw" >"$PW_FILE"
)
chown ${cfg.tokenOwner}: "$PW_FILE"
chmod 600 "$PW_FILE"
echo "wrote admin UI password to $PW_FILE"
fi
for u in ${lib.escapeShellArgs cfg.roles}; do
ensure_user "$u"
ensure_token "$u"
done
ADMIN_TOKEN=$(cat "$TOKENS_DIR/${cfg.adminUser}.token")
# org
if [ "$(api GET /orgs/${cfg.org})" = 404 ]; then
must "$(api POST /orgs '{"username":"${cfg.org}","visibility":"private"}')" \
"create org ${cfg.org}"
echo "created org ${cfg.org}"
fi
# team (write access for every role; review power comes from the
# branch-protection approver whitelist, not team permission)
must "$(api GET "/orgs/${cfg.org}/teams/search?q=crew")" "search teams"
tid=$(jq -r '.data[] | select(.name == "crew") | .id' <"$RESP" | head -n1)
if [ -z "$tid" ]; then
must "$(api POST /orgs/${cfg.org}/teams '{"name":"crew","permission":"write","includes_all_repositories":true,"units":["repo.code","repo.issues","repo.pulls","repo.releases"]}')" \
"create team crew"
tid=$(jq -r .id <"$RESP")
echo "created team crew (id $tid)"
fi
for u in ${lib.escapeShellArgs cfg.roles}; do
must "$(api PUT "/teams/$tid/members/$u")" "add $u to team crew"
done
# working repo
if [ "$(api GET /repos/${cfg.org}/${cfg.repoName})" = 404 ]; then
must "$(api POST /orgs/${cfg.org}/repos '{"name":"${cfg.repoName}","private":true,"auto_init":true,"default_branch":"main"}')" \
"create repo ${cfg.org}/${cfg.repoName}"
echo "created repo ${cfg.org}/${cfg.repoName}"
fi
# branch protection on main
if [ "$(api GET /repos/${cfg.org}/${cfg.repoName}/branch_protections/main)" = 404 ]; then
must "$(api POST /repos/${cfg.org}/${cfg.repoName}/branch_protections '${branchProtectionJson}')" \
"protect main"
echo "protected main (required approver(s): ${lib.concatStringsSep ", " cfg.requiredApprovers})"
fi
echo "reforge: provisioning converged."
'';
};
in
{
options.reforge = {
enable = lib.mkEnableOption "Local Forgejo sandbox for multi-agent dev-team simulation (reforge)";
httpPort = lib.mkOption {
type = lib.types.port;
default = 3030;
description = "Localhost HTTP port for the sandbox Forgejo (3000 is left free for ad-hoc dev servers).";
};
org = lib.mkOption {
type = lib.types.str;
default = "sandbox-team";
description = "Organization the role accounts and working repos live under.";
};
repoName = lib.mkOption {
type = lib.types.str;
default = "sandbox-project";
description = "Working repository created under the org (auto-init, main protected). The smoke test exercises this repo.";
};
roles = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"backend-dev"
"frontend-dev"
"security-lead"
"reviewer"
];
description = ''
Role usernames to provision. Each gets an account + API token; one
agent session per role. There must be a matching brief
<agentsDir>/<role>.md for every role you launch.
'';
};
requiredApprovers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "security-lead" ];
description = ''
Usernames whose approval counts toward the required-approvals gate
on main (approvals whitelist). Must be a subset of roles.
'';
};
adminUser = lib.mkOption {
type = lib.types.str;
default = "sandbox-admin";
description = "Instance admin account used by the provisioning script's API calls and the seed/kickoff scripts.";
};
tokenOwner = lib.mkOption {
type = lib.types.str;
default = "root";
description = ''
Local account that owns the generated token files under
${tokensDir} (mode 0600). Set to the human user who will launch the
per-role agent sessions.
'';
};
configDir = lib.mkOption {
type = lib.types.path;
description = ''
Instance run configuration: a directory containing manifest.txt
(repo set, pinned bases, declared targets), charter.md (the
standard changes are judged against), agenda.md (this run's
worklist), and issues.tsv (agenda items to file as issues at
kickoff). This is the per-project data an adopter fills in see
the `reforge` flake template. Baked into the store, so a change is
a rebuild.
'';
};
agentsDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Directory of agent briefs injected as each session's CLAUDE.md:
common.md (shared preamble, @ROLE@-templated), one <role>.md per
role, and orchestrator.md (the autonomous-driver playbook). Null
uses the engine's generic briefs; set it to your own directory to
customize the role framing for your project.
'';
};
refsDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Optional read-only reference corpus (mirrors of well-known
codebases) agents may cite. Null omits the refs instruction from
briefs entirely. Not baked into the store passed as a runtime
path (REFORGE_REFS_DIR) since it is typically large and mutable.
'';
};
forgejoMcpPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.callPackage ../packages/forgejo-mcp.nix { };
defaultText = lib.literalExpression "pkgs.callPackage ../packages/forgejo-mcp.nix { }";
description = "The forgejo-mcp server binary sessions use to drive the forge over MCP.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lib.all (u: lib.elem u cfg.roles) cfg.requiredApprovers;
message = "reforge.requiredApprovers must be a subset of reforge.roles approvers need provisioned accounts.";
}
];
environment.systemPackages = [
forgejoMcp
reforgeScripts
];
services.forgejo = {
enable = true;
database.type = "sqlite3"; # single host, a handful of sequential sessions
# stateDir defaults to /var/lib/forgejo — real persisted state,
# survives rebuilds; StateDirectory is managed by the upstream module.
settings = {
server = {
DOMAIN = "localhost";
HTTP_ADDR = "127.0.0.1"; # sandbox: loopback only, no firewall hole
HTTP_PORT = cfg.httpPort;
ROOT_URL = "http://localhost:${toString cfg.httpPort}/";
DISABLE_SSH = true; # tokens over HTTP; no SSH server on the sandbox
};
service.DISABLE_REGISTRATION = true; # accounts come from provisioning only
repository.DEFAULT_BRANCH = "main";
};
};
systemd.tmpfiles.rules = [
"d ${stateDir} 0751 root root -"
"d ${tokensDir} 0700 ${cfg.tokenOwner} - -"
];
# Oneshot, re-run on every switch; every step converges (check-before-
# create), so RemainAfterExit + wantedBy multi-user is safe to leave on.
systemd.services.reforge-provision = {
description = "Provision reforge role accounts, tokens, org, repo, branch protection";
after = [ "forgejo.service" ];
requires = [ "forgejo.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = lib.getExe provisionScript;
};
};
};
}