mod.rs keeps the shared admin/http helpers + ensure_all/sync_agent; user/token provisioning, repo/org/mirror ops, and the trust-boundary PR-merge primitives move to users.rs / repos.rs / pr_merge.rs
280 lines
14 KiB
Rust
280 lines
14 KiB
Rust
//! Optional Forgejo wiring — per-agent user + token provisioning,
|
|
//! config-repo mirroring, meta read-access grants. Also seeds
|
|
//! `internal/docs` — a private repo every agent gets read-only
|
|
//! collaborator access to for operator-curated shared content.
|
|
//! No-op when `hive-forge` isn't running. Full design: `docs/forge.md`.
|
|
|
|
mod pr_merge;
|
|
mod repos;
|
|
mod users;
|
|
|
|
pub use pr_merge::{
|
|
ForgeMergeError, config_repo, fetch_pr_head_into_applied, ff_push_to_main, mark_pr_merged,
|
|
pr_head_sha,
|
|
};
|
|
pub use repos::{
|
|
create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo,
|
|
ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access,
|
|
};
|
|
pub use users::{core_token, ensure_user_for, provision_user_token};
|
|
|
|
use anyhow::{Context, Result};
|
|
use reqwest::StatusCode;
|
|
|
|
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
|
|
use users::{
|
|
ensure_config_org_avatar, ensure_core_avatar, ensure_core_user_and_token,
|
|
ensure_repo_creation_disabled, ensure_user_email,
|
|
};
|
|
|
|
const FORGE_CONTAINER: &str = "hive-forge";
|
|
pub(crate) const FORGE_HTTP: &str = "http://localhost:3000";
|
|
/// Forgejo org grouping every agent's config repo. Core is a site admin
|
|
/// and reads + writes every repo here. As of the agent-config-PR flow each
|
|
/// agent is a **write collaborator on its own** `agent-configs/<name>` repo —
|
|
/// the editable PR surface it pushes config-change branches to — but `main` is
|
|
/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
|
|
/// handler lands on it (operator approval required; the agent can't push
|
|
/// `main` or self-merge). The repos remain private, so an agent still can't
|
|
/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re
|
|
/// never force-pushes; the `push_config` mirror runs best-effort until the
|
|
/// PR-merge flow retires it.
|
|
const CONFIG_ORG: &str = "agent-configs";
|
|
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
|
/// that every agent gets read-only access to. Agents use it as a
|
|
/// common reference without the operator having to bake content into
|
|
/// the system prompt or rely on `/shared`. Only the manager + operator
|
|
/// (i.e. `core` user) can push.
|
|
const SHARED_ORG: &str = "internal";
|
|
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
|
|
/// at `{FORGE_HTTP}/internal/docs.git`.
|
|
const SHARED_DOCS_REPO: &str = "docs";
|
|
/// The hive-wide knowledge repo inside `SHARED_ORG`. Public — agents
|
|
/// can fork it and open PRs without explicit collaborator grants.
|
|
/// Bind-mounted read-only into every container at `/knowledge`.
|
|
/// See `hive-c0re/src/knowledge.rs`.
|
|
const KNOWLEDGE_REPO: &str = crate::knowledge::REPO;
|
|
/// Forgejo org that owns agent-created repos. Agents can't create
|
|
/// repos with their own token (`max_repo_creation = 0`); instead hive-c0re
|
|
/// creates them here and adds the requesting agent as a **write** member
|
|
/// (not owner/admin). Because the org — not the agent — owns the repo,
|
|
/// perms stay c0re-managed and branch protection (referencing
|
|
/// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This
|
|
/// is the "agents namespace" repos land in by default.
|
|
const AGENTS_ORG: &str = "agents";
|
|
/// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by
|
|
/// hive-c0re (so perms can be set before anyone joins); the operator adds
|
|
/// herself via the forge UI / hivectl. Branch protection on agents-org repos
|
|
/// references this team by name for the merge/approval whitelist, so the
|
|
/// rule never hardcodes a specific reviewer agent (which may not exist).
|
|
const OPERATORS_TEAM: &str = "operators";
|
|
/// Hive-managed Forgejo namespaces that agent-initiated repo creation must
|
|
/// never target. `internal` is operator-curated shared content;
|
|
/// `agent-configs` + `core` are hive-c0re-internal mirror/meta namespaces.
|
|
/// (`hyperhive` is NOT managed — it's just a repo that happens to be built
|
|
/// by this hive.) hive-c0re's create path forces [`AGENTS_ORG`], so this is
|
|
/// a defensive guard against any future caller passing an explicit owner.
|
|
const HIVE_MANAGED_NAMESPACES: &[&str] = &[SHARED_ORG, CONFIG_ORG, "core"];
|
|
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
|
|
/// `core/meta` (the `core` user's own namespace — no org needed).
|
|
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG, AGENTS_ORG];
|
|
|
|
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
|
/// `nixos-container list` is just a directory scan in /etc. Routed
|
|
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
|
/// unprivileged (privsep).
|
|
pub async fn is_present() -> bool {
|
|
let Ok(stdout) = crate::priv_client::list_containers().await else {
|
|
return false;
|
|
};
|
|
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
|
|
}
|
|
|
|
/// Run `forgejo admin <args>` inside the hive-forge container as the
|
|
/// forgejo user (the only uid with write access to the state dir).
|
|
/// Returns stdout on success; bails with stderr context on failure.
|
|
async fn forge_admin(args: &[&str]) -> Result<String> {
|
|
// Route through hive-priv (root helper) because `nixos-container run`
|
|
// uses nsenter to enter the container's namespaces, which requires root.
|
|
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
|
|
// nsenter directly — doing so produces:
|
|
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
|
|
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
|
|
.await
|
|
.with_context(|| format!("forgejo admin {} (via hive-priv)", args.join(" ")))?;
|
|
Ok(stdout)
|
|
}
|
|
|
|
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
|
|
/// and `Authorization: token <token>`, returns the HTTP status code.
|
|
/// All Forgejo API calls that don't shell out to `forgejo admin` go
|
|
/// through here — one place for auth header, content-type, error
|
|
/// propagation, and the shared reqwest Client.
|
|
/// Returns the response status **and body**. The body lets callers log
|
|
/// *why* Forgejo rejected a request (e.g. the validation message on a
|
|
/// 422); status-only callers just bind `(status, _)`. Body read is
|
|
/// best-effort — a read error yields an empty string rather than
|
|
/// failing the whole call.
|
|
async fn forge_http(
|
|
method: reqwest::Method,
|
|
url: &str,
|
|
token: &str,
|
|
body: &str,
|
|
) -> Result<(StatusCode, String)> {
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.request(method, url)
|
|
.header("Authorization", format!("token {token}"))
|
|
.header("Content-Type", "application/json")
|
|
.body(body.to_owned())
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("forge HTTP request to {url}"))?;
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
Ok((status, text))
|
|
}
|
|
|
|
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
|
/// repo creation must never target — `internal` (operator-curated
|
|
/// shared content) + `agent-configs` / `core` (hive-c0re-internal). The
|
|
/// create path forces [`AGENTS_ORG`], so this guards a future surface that
|
|
/// might accept an explicit owner.
|
|
#[must_use]
|
|
pub fn is_hive_managed_namespace(ns: &str) -> bool {
|
|
HIVE_MANAGED_NAMESPACES.contains(&ns)
|
|
}
|
|
|
|
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
|
|
/// a mirrored config repo, read access to `core/meta`, and the `meta`
|
|
/// remote in its proposed repo. All operations are idempotent; failures
|
|
/// are logged as warnings but don't abort the caller.
|
|
///
|
|
/// `core_token` is `core_token()` — passed in so callers that already
|
|
/// fetched it don't re-read the file. Pass `None` to skip the
|
|
/// `meta_read_access` step (safe: the access grant is best-effort).
|
|
///
|
|
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
|
/// (per-rebuild) so the two paths stay equivalent.
|
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
|
if let Err(e) = ensure_user_for(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
|
}
|
|
// Align email to match the git user.email set by meta::render_flake
|
|
// so commits link to the agent's Forgejo profile. Best-effort;
|
|
// also patches up agents created before this fix (old @hive.local).
|
|
ensure_user_email(name).await;
|
|
// Block direct agent-initiated repo creation: agents create
|
|
// repos through hive-c0re, never with their own token. Idempotent +
|
|
// marker-guarded; also covers agents provisioned before this landed.
|
|
ensure_repo_creation_disabled(name).await;
|
|
// Mirror the agent's applied config repo into agent-configs.
|
|
// ensure_config_repo is idempotent; push_config catches any
|
|
// drift since the last run — e.g. the startup migration just
|
|
// relocated `deployed/0`, or a deploy landed while the forge
|
|
// was down.
|
|
if let Err(e) = ensure_config_repo(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
|
}
|
|
if let Err(e) = push_config(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
|
}
|
|
// Grant read-only access to core/meta and wire the `meta` remote
|
|
// into the proposed repo so agents can fetch their deployment context.
|
|
if let Some(token) = core_token
|
|
&& let Err(e) = meta_read_access(name, token).await
|
|
{
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
|
}
|
|
if let Err(e) = ensure_meta_remote(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
|
}
|
|
// Grant read-only access to internal/docs so the agent can clone
|
|
// the operator-curated shared skills/runbook repo. Best-effort.
|
|
if let Some(token) = core_token
|
|
&& let Err(e) = shared_docs_access(name, token).await
|
|
{
|
|
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
|
|
}
|
|
// internal/knowledge is public — no per-agent collaborator grant needed.
|
|
}
|
|
|
|
/// Sweep every existing container (manager + sub-agents) and ensure
|
|
/// each has a forgejo user + token, plus an `agent-configs/<name>`
|
|
/// repo mirroring its applied config. Also seeds the `core` admin
|
|
/// user (hive-c0re's own identity for pushing the meta repo + driving
|
|
/// the API), the `agent-configs` org, and the `core/meta` repo.
|
|
/// Called once at hive-c0re startup. Per-step failures are logged
|
|
/// but don't abort the sweep.
|
|
pub async fn ensure_all() {
|
|
if !is_present().await {
|
|
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
|
|
return;
|
|
}
|
|
let core_token = match ensure_core_user_and_token().await {
|
|
Ok(t) => Some(t),
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
|
|
None
|
|
}
|
|
};
|
|
if let Some(token) = core_token.as_deref() {
|
|
for org in SEEDED_ORGS {
|
|
if let Err(e) = ensure_org(org, token).await {
|
|
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
|
|
}
|
|
}
|
|
// Seed the operator-declared pull-mirrors (nix `forge.mirrors` +
|
|
// the CI-auto `actions/checkout`, forwarded via the
|
|
// `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so
|
|
// this is independent of the SEEDED_ORGS loop above.
|
|
ensure_mirrors(token).await;
|
|
// Provision the operator merge-gate team (empty) inside BOTH the
|
|
// agents org and the agent-configs org so branch protection in each
|
|
// can reference it before anyone joins. Gitea teams are org-scoped —
|
|
// missing the agent-configs copy 422'd every config-repo protection
|
|
// apply, leaving those repos unprotected and letting operator-merged
|
|
// config PRs bypass the deploy pipeline. The operator adds herself as
|
|
// a member out-of-band.
|
|
for org in [AGENTS_ORG, CONFIG_ORG] {
|
|
if let Err(e) = ensure_operators_team(org, token).await {
|
|
tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed");
|
|
}
|
|
}
|
|
// Meta repo lives at core/meta — pushed from git_commit in
|
|
// meta.rs on every deploy/lock-update. Make sure it exists
|
|
// before the first push hits a 404.
|
|
if let Err(e) = ensure_repo("meta", token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
|
}
|
|
// Seed the shared docs repo. internal is already in
|
|
// SEEDED_ORGS above so the org exists; ensure the repo itself.
|
|
if let Err(e) = ensure_shared_docs_repo(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
|
|
}
|
|
// Seed the hive-wide knowledge repo.
|
|
if let Err(e) = ensure_knowledge_repo(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed");
|
|
}
|
|
// Clone knowledge repo locally so it can be bind-mounted into agents.
|
|
if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
|
|
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
|
|
}
|
|
if let Err(e) = ensure_core_avatar(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
|
}
|
|
if let Err(e) = ensure_config_org_avatar(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
|
|
}
|
|
}
|
|
let Ok(containers) = crate::lifecycle::list().await else {
|
|
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
|
return;
|
|
};
|
|
for c in containers {
|
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
|
continue;
|
|
};
|
|
sync_agent(name, core_token.as_deref()).await;
|
|
}
|
|
}
|