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>
383 lines
15 KiB
Nix
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;
|
|
};
|
|
};
|
|
};
|
|
}
|