Closes the #1787 loop — the sanctioned create path now that agents can't create repos directly. Adds: - wire: Request::CreateRepo{repo} + Response::RepoCreated{full_name, clone_url} (hive-sh4re). - agent_server: dispatch_shared arm + handle_create_repo — validates the repo name, then forge::create_agent_repo (org-owned repo, agent=write collaborator, operator-team branch protection). Returns the full name + clone url so the agent can git clone immediately. - MCP: create_repo tool + CreateRepoArgs in the harness. - a new opt-in ToolGroup::Forge (=[create_repo]) so the operator controls which agents can spin up repos (least privilege). Workspace clippy -D warnings, cargo test, nix fmt all green.
1167 lines
50 KiB
Rust
1167 lines
50 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`.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
use base64::Engine;
|
|
use reqwest::StatusCode;
|
|
use tokio::process::Command;
|
|
|
|
use crate::coordinator::Coordinator;
|
|
|
|
const FORGE_CONTAINER: &str = "hive-forge";
|
|
pub(crate) const FORGE_HTTP: &str = "http://localhost:3000";
|
|
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
|
/// Where the host-side `core` admin token lives. Used by hive-c0re
|
|
/// itself to push the meta repo + drive admin API calls (org
|
|
/// creation, future webhook setup, etc.). Root-only.
|
|
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
|
|
// Forge provisioning markers (`forge/core-avatar-set`,
|
|
// `forge/agent-configs-avatar-set`, `forge/email-aligned-<name>`) live
|
|
// in `crate::paths` — one-shot guards: the upload/align runs once, the
|
|
// marker is written, subsequent startups skip. Delete one to force its
|
|
// step to re-run.
|
|
// Avatar PNGs are loaded at runtime from
|
|
// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the
|
|
// helpers in `hive_sh4re::assets`. The `agent-configs.png` is
|
|
// rendered from its SVG during the `hyperhive-assets` derivation's
|
|
// build.
|
|
/// Forgejo org grouping every agent's applied config repo. Core is a
|
|
/// site admin and reads + writes every repo here; agents are NOT
|
|
/// members and the repos are private, so no agent — not even the one
|
|
/// a repo describes — can reach a config repo through the forge. The
|
|
/// applied repos stay hive-c0re-owned on disk; this org is just a
|
|
/// mirror target core pushes to.
|
|
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 (#1787). 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 (#1787). `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];
|
|
/// Per-agent token scopes (broad-but-not-admin). See
|
|
/// `docs/forge.md::Token scopes` for the per-scope rationale.
|
|
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
|
|
|
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
|
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
|
/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the
|
|
/// token's own scope gate runs before the user-permission check.
|
|
/// See `docs/forge.md::Token scopes`.
|
|
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Pull the access token out of forgejo's success message. Format
|
|
/// has shifted across versions (table form vs. "Access token was
|
|
/// successfully created: <hex>"), so just hunt the output for the
|
|
/// first long hex-looking word.
|
|
fn extract_token(output: &str) -> Option<String> {
|
|
output
|
|
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
|
|
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
/// Canonical email address for a hive agent's Forgejo account.
|
|
/// Must match the `user.email` set by `meta::render_flake` so commits
|
|
/// by the agent link back to their Forgejo profile page.
|
|
fn agent_email(name: &str) -> String {
|
|
format!("{name}@hyperhive.local")
|
|
}
|
|
|
|
/// 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.
|
|
async fn forge_http(
|
|
method: reqwest::Method,
|
|
url: &str,
|
|
token: &str,
|
|
body: &str,
|
|
) -> Result<StatusCode> {
|
|
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}"))?;
|
|
Ok(resp.status())
|
|
}
|
|
|
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
|
/// returns a "user already exists" error which we treat as success.
|
|
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
|
/// `core` user that drives the API. `password` picks the initial
|
|
/// account password: `None` uses `--random-password` (the existing
|
|
/// agent provisioning shape — the password is never read, agents auth
|
|
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
|
|
/// in `hivectl` can set a real password for matrix-style web-UI login.
|
|
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
|
|
let email = agent_email(name);
|
|
let mut args = vec!["user", "create", "--username", name, "--email", &email];
|
|
match password {
|
|
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
|
|
None => args.extend(["--random-password", "--must-change-password=false"]),
|
|
}
|
|
if admin {
|
|
args.push("--admin");
|
|
}
|
|
let result = forge_admin(&args).await;
|
|
match result {
|
|
Ok(_) => {
|
|
tracing::info!(%name, "forge: created user");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
// Forgejo's "already exists" error wording varies; just
|
|
// try the next step and let token issuance surface a
|
|
// real failure if the user truly isn't there.
|
|
let msg = format!("{e:#}");
|
|
if msg.contains("already exists") || msg.contains("user already") {
|
|
tracing::debug!(%name, "forge: user already exists");
|
|
Ok(())
|
|
} else {
|
|
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set the forgejo password for an existing user. Used by the operator
|
|
/// path in `hivectl forge create-user --password` so re-running on an
|
|
/// already-created account still updates the password (covers the
|
|
/// "I forgot the password I set last week" case + the "argus retried
|
|
/// the verb to verify the fix" case — `forgejo admin user create`
|
|
/// silently skips a password change once the account exists). Idempotent
|
|
/// from the operator's point of view: same password input → same final
|
|
/// account state.
|
|
async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
|
let args = [
|
|
"user",
|
|
"change-password",
|
|
"--username",
|
|
name,
|
|
"--password",
|
|
password,
|
|
];
|
|
forge_admin(&args)
|
|
.await
|
|
.with_context(|| format!("forgejo admin user change-password {name}"))?;
|
|
tracing::info!(%name, "forge: changed user password");
|
|
Ok(())
|
|
}
|
|
|
|
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
|
/// Existing agents were created with `{name}@hive.local`; this corrects
|
|
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
|
/// Best-effort: failures are warned, not propagated.
|
|
///
|
|
/// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first
|
|
/// success and skips the PATCH on all subsequent calls. This prevents
|
|
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
|
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
|
///
|
|
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
|
|
/// than `forgejo admin user edit` because the CLI dropped the `edit`
|
|
/// subcommand somewhere between forgejo 8 and current. Body includes
|
|
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
|
|
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
|
|
async fn ensure_user_email(name: &str) {
|
|
let marker = crate::paths::forge_email_aligned_marker(name);
|
|
if marker.exists() {
|
|
return;
|
|
}
|
|
let Some(token) = core_token() else {
|
|
tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet");
|
|
return;
|
|
};
|
|
let email = agent_email(name);
|
|
// `login_name` is required by Forgejo's EditUserOption validator.
|
|
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
|
|
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
|
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
|
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
|
Ok(status) if status.is_success() => {
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(&marker, "").ok();
|
|
tracing::info!(%name, %email, "forge: user email aligned");
|
|
}
|
|
Ok(status) if status == reqwest::StatusCode::FORBIDDEN => {
|
|
// Core token missing admin scope — see
|
|
// `docs/forge.md::Token scopes` migration note.
|
|
tracing::warn!(
|
|
%name, %email, %status,
|
|
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
|
);
|
|
}
|
|
Ok(status) => {
|
|
tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success");
|
|
}
|
|
Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"),
|
|
}
|
|
}
|
|
|
|
/// Disable direct repo creation for agent `name` by setting
|
|
/// `max_repo_creation = 0` on its Forgejo account (#1787). Agents must
|
|
/// create repos *through hive-c0re* (which owns the perms), never with
|
|
/// their own token — a write-scoped token can otherwise create + own
|
|
/// repos and self-merge, bypassing the operator-only-merge policy.
|
|
///
|
|
/// `max_repo_creation = 0` means `CanCreateRepo()` is false for any
|
|
/// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`),
|
|
/// so creation is refused while push / PR / clone stay intact. **Existing
|
|
/// repos are untouched** — this only blocks *new* direct creation.
|
|
///
|
|
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
|
|
/// agent (delete the marker to re-apply). Body carries `login_name` +
|
|
/// `source_id` for the same reason `ensure_user_email` does — omitting
|
|
/// `login_name` makes Forgejo's `EditUserOption` validator reset
|
|
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
|
|
async fn ensure_repo_creation_disabled(name: &str) {
|
|
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
|
if marker.exists() {
|
|
return;
|
|
}
|
|
let Some(token) = core_token() else {
|
|
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
|
return;
|
|
};
|
|
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
|
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
|
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
|
Ok(status) if status.is_success() => {
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(&marker, "").ok();
|
|
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
|
}
|
|
Ok(status) if status == reqwest::StatusCode::FORBIDDEN => {
|
|
tracing::warn!(
|
|
%name, %status,
|
|
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
|
);
|
|
}
|
|
Ok(status) => {
|
|
tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mint a fresh access token for `name`. Token name is suffixed with
|
|
/// a monotonic clock so re-issuing doesn't collide with an existing
|
|
/// token of the same name in the DB. `scopes` is the scope string
|
|
/// passed to `forgejo admin user generate-access-token --scopes`;
|
|
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
|
/// bootstrap `core` user.
|
|
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
|
let token_name = format!(
|
|
"{TOKEN_NAME_PREFIX}-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_secs())
|
|
);
|
|
let stdout = forge_admin(&[
|
|
"user",
|
|
"generate-access-token",
|
|
"--username",
|
|
name,
|
|
"--token-name",
|
|
&token_name,
|
|
"--scopes",
|
|
scopes,
|
|
])
|
|
.await?;
|
|
let token = extract_token(&stdout)
|
|
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
|
|
tracing::debug!(%name, %token_name, "forge: minted access token");
|
|
Ok(token)
|
|
}
|
|
|
|
/// Mint a fresh Forgejo access token for an agent and write it to the
|
|
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
|
/// cannot write to agent-owned (0755) state directories directly.
|
|
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
|
let token = mint_token(name, TOKEN_SCOPES).await?;
|
|
crate::priv_client::write_agent_forge_token(name, &token)
|
|
.await
|
|
.with_context(|| format!("write forge-token for {name} via hive-priv"))
|
|
}
|
|
|
|
/// Mint a fresh Forgejo access token for the `core` admin user and
|
|
/// write it directly to `path`. Unlike agent tokens this path is owned
|
|
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
|
|
/// write is both correct and necessary (no priv round-trip).
|
|
async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(path, format!("{token}\n"))
|
|
.with_context(|| format!("write core token to {}", path.display()))?;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
tracing::info!(path = %path.display(), "forge: persisted core access token");
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure `name` has a forgejo user + token file. Always re-mints the
|
|
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
|
/// Safe to call on every spawn and on every hive-c0re startup.
|
|
pub async fn ensure_user_for(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
ensure_user_exists(name, false, None).await?;
|
|
ensure_user_email(name).await;
|
|
mint_and_persist_agent_token(name).await
|
|
}
|
|
|
|
/// Provision a forgejo user for `name` and return the freshly-minted
|
|
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
|
/// disk — the caller is responsible for storing it. Used by `hivectl
|
|
/// forge create-user` for human (non-agent) accounts so we don't create
|
|
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
|
/// aren't agents.
|
|
///
|
|
/// `password` picks the account password. `None` keeps the existing
|
|
/// random-throwaway shape (caller doesn't need web UI access — token
|
|
/// alone is enough). `Some(pw)` sets `pw` as the password, including
|
|
/// running `forgejo admin user change-password` if the account already
|
|
/// exists, so the operator can log into the forge web UI afterwards.
|
|
/// Idempotent: re-running with the same `Some(pw)` lands on the same
|
|
/// final state.
|
|
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
|
|
if !is_present().await {
|
|
anyhow::bail!(
|
|
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
|
|
);
|
|
}
|
|
ensure_user_exists(name, false, password).await?;
|
|
if let Some(pw) = password {
|
|
// `user create` silently no-ops on an existing account, so
|
|
// we run change-password unconditionally when the caller
|
|
// asked for a specific password — keeps the verb idempotent
|
|
// for "set or reset" use.
|
|
change_user_password(name, pw).await?;
|
|
}
|
|
ensure_user_email(name).await;
|
|
mint_token(name, TOKEN_SCOPES).await
|
|
}
|
|
|
|
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
|
/// remember it so subsequent startups don't re-upload. Best-effort
|
|
/// — any non-2xx is logged at the caller; the project runs fine
|
|
/// with the default hash identicon.
|
|
async fn ensure_core_avatar(token: &str) -> Result<()> {
|
|
let marker = crate::paths::forge_core_avatar_marker();
|
|
if marker.exists() {
|
|
return Ok(());
|
|
}
|
|
let png_path = hive_sh4re::assets::core_avatar_png();
|
|
let png_bytes = tokio::fs::read(&png_path)
|
|
.await
|
|
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
|
let body = format!(
|
|
r#"{{"image":"{}"}}"#,
|
|
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
|
);
|
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("set core avatar: HTTP {status}");
|
|
}
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(marker, "").ok();
|
|
tracing::info!("forge: set core user avatar to hyperhive logo");
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the `agent-configs` org's Forgejo avatar to the
|
|
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
|
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
|
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
|
/// JSON body — same shape as the admin user endpoint above.
|
|
async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
|
let marker = crate::paths::forge_config_org_avatar_marker();
|
|
if marker.exists() {
|
|
return Ok(());
|
|
}
|
|
let png_path = hive_sh4re::assets::config_org_avatar_png();
|
|
let png_bytes = tokio::fs::read(&png_path)
|
|
.await
|
|
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
|
let body = format!(
|
|
r#"{{"image":"{}"}}"#,
|
|
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
|
);
|
|
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
|
}
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(marker, "").ok();
|
|
tracing::info!(
|
|
org = CONFIG_ORG,
|
|
"forge: set org avatar to configs-stack logo"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Outcome of probing whether the persisted core token still works
|
|
/// against the *current* forge. Existence on disk is not validity: a
|
|
/// token minted before a forge rebuild / re-provision is unknown to the
|
|
/// new forge's DB and 401s on every call — which silently breaks the
|
|
/// hive-ci runner-registration prefetch (it reads this same token to
|
|
/// fetch a runner registration token).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CoreTokenCheck {
|
|
/// Token authenticated successfully — keep using it.
|
|
Valid,
|
|
/// Forge explicitly rejected the token (401/403) — re-mint.
|
|
Invalid,
|
|
/// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a
|
|
/// transient: keep the existing token and let a later ensure pass
|
|
/// re-check once the forge is responsive. Re-minting here would both
|
|
/// fail (mint needs the forge too) and churn tokens needlessly.
|
|
Indeterminate,
|
|
}
|
|
|
|
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
|
/// Pure so the decision logic is unit-testable without a live forge.
|
|
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
|
if status.is_success() {
|
|
CoreTokenCheck::Valid
|
|
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
|
CoreTokenCheck::Invalid
|
|
} else {
|
|
CoreTokenCheck::Indeterminate
|
|
}
|
|
}
|
|
|
|
/// Probe whether `token` is still accepted by the current forge with a
|
|
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
|
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
|
/// interpreted.
|
|
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
|
let url = format!("{FORGE_HTTP}/api/v1/user");
|
|
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
|
Ok(status) => classify_core_token_status(status),
|
|
Err(e) => {
|
|
tracing::debug!(
|
|
error = %e,
|
|
"forge: core-token probe could not reach forge; treating as indeterminate"
|
|
);
|
|
CoreTokenCheck::Indeterminate
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ensure the bootstrap `core` admin user + a token at
|
|
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
|
/// API calls (org creation, meta-repo push, and the hive-ci
|
|
/// runner-registration prefetch). Returns the token.
|
|
///
|
|
/// Idempotent, but validity-aware: when a token file is already present
|
|
/// it is **probed against the current forge** before being trusted. A
|
|
/// token persisted before a forge rebuild / re-provision is stale (the
|
|
/// new forge DB doesn't know it) and would 401 every caller — so on a
|
|
/// definitive rejection the token is re-minted. A merely-unreachable
|
|
/// forge leaves the existing token in place (a later ensure pass
|
|
/// re-checks) rather than churning tokens on a transient.
|
|
async fn ensure_core_user_and_token() -> Result<String> {
|
|
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
|
if let Ok(existing) = std::fs::read_to_string(path) {
|
|
let trimmed = existing.trim().to_owned();
|
|
if !trimmed.is_empty() {
|
|
match check_core_token(&trimmed).await {
|
|
CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed),
|
|
CoreTokenCheck::Invalid => {
|
|
tracing::warn!(
|
|
path = %path.display(),
|
|
"forge: persisted core token rejected by forge (stale after rebuild?); \
|
|
re-minting"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ensure_user_exists("core", true, None).await?;
|
|
mint_and_persist_core_token(path).await?;
|
|
let raw = std::fs::read_to_string(path)
|
|
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
|
Ok(raw.trim().to_owned())
|
|
}
|
|
|
|
/// JSON body for a private, empty repo defaulting to `main`.
|
|
fn repo_body(name: &str) -> String {
|
|
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
|
}
|
|
|
|
/// JSON body for a public, empty repo defaulting to `main`.
|
|
fn repo_body_public(name: &str) -> String {
|
|
format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#)
|
|
}
|
|
|
|
/// Set an existing repo to public visibility. No-op if the repo is
|
|
/// already public. Used for `internal/knowledge` which may have been
|
|
/// created as private on an older deployment.
|
|
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
|
let status = forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?;
|
|
match status.as_u16() {
|
|
200 => {
|
|
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Create `name` inside org `org` as a public repo. Idempotent.
|
|
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
|
create_repo(
|
|
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
|
&repo_body_public(name),
|
|
token,
|
|
&format!("{org}/{name}"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// POST a repo-creation request to `url` and fold "already exists"
|
|
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
|
|
/// for log + error context.
|
|
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
|
|
let status = forge_http(reqwest::Method::POST, url, token, body).await?;
|
|
match status.as_u16() {
|
|
201 => {
|
|
tracing::info!(%label, "forge: created repo");
|
|
Ok(())
|
|
}
|
|
409 | 422 => {
|
|
tracing::debug!(%label, "forge: repo already exists");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Create a repo in the token-owner's own namespace. `token` belongs
|
|
/// to the user we want the repo owned by (we use `core`'s token for
|
|
/// `core/meta`). Idempotent.
|
|
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
|
create_repo(
|
|
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
|
&repo_body(name),
|
|
token,
|
|
&format!("core/{name}"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
|
/// Idempotent.
|
|
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
|
create_repo(
|
|
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
|
&repo_body(name),
|
|
token,
|
|
&format!("{org}/{name}"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Read the persisted core token, or None when the forge isn't
|
|
/// seeded yet. Cheap — just a file read.
|
|
pub fn core_token() -> Option<String> {
|
|
std::fs::read_to_string(CORE_TOKEN_PATH)
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
|
/// Best-effort: returns Err which callers log + ignore. No-op when
|
|
/// the core token isn't present (forge not enabled).
|
|
pub async fn push_meta(dir: &Path) -> Result<()> {
|
|
let Some(token) = core_token() else {
|
|
return Ok(());
|
|
};
|
|
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
|
|
// any-username:<token>; using `core` matches the owner so the
|
|
// remote name is self-describing.
|
|
let url = format!("http://core:{token}@localhost:3000/core/meta.git");
|
|
let out = Command::new("git")
|
|
.current_dir(dir)
|
|
.args(["push", "--force", &url, "HEAD:main"])
|
|
.output()
|
|
.await
|
|
.context("invoke git push core/meta")?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git push core/meta failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
tracing::info!("forge: pushed meta to core/meta");
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure the `agent-configs/<name>` repo exists so the first
|
|
/// `push_config` doesn't 404. No-op when the forge isn't running or
|
|
/// the core token isn't minted yet. Safe to call on every spawn and
|
|
/// on every startup.
|
|
pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
let Some(token) = core_token() else {
|
|
return Ok(());
|
|
};
|
|
ensure_org_repo(CONFIG_ORG, name, &token).await
|
|
}
|
|
|
|
/// Ensure the `internal/docs` repo exists. Called once at startup
|
|
/// after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo`
|
|
/// treats 409 as success.
|
|
pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
|
|
ensure_org_repo(SHARED_ORG, SHARED_DOCS_REPO, core_token).await
|
|
}
|
|
|
|
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
|
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
|
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
|
|
/// without authentication hassle.
|
|
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
|
let url =
|
|
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
|
|
let body = r#"{"permission":"read"}"#;
|
|
let out = Command::new("curl")
|
|
.args([
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"-X",
|
|
"PUT",
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"-H",
|
|
&format!("Authorization: token {core_token}"),
|
|
"-d",
|
|
body,
|
|
&url,
|
|
])
|
|
.output()
|
|
.await
|
|
.context("invoke curl PUT internal/docs/collaborators")?;
|
|
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
match code.as_str() {
|
|
"204" => {
|
|
tracing::info!(%name, "forge: granted shared-docs read access");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!(
|
|
"PUT {SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name} returned HTTP {other}"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Ensure the `internal/knowledge` repo exists and is public.
|
|
/// Called once at startup after `ensure_org(SHARED_ORG)`. Idempotent.
|
|
///
|
|
/// The repo is created as public so any agent with a forge account can
|
|
/// fork it and open PRs to contribute. Existing deployments that ended
|
|
/// up with a private repo are patched to public on the next hive-c0re
|
|
/// startup via `set_repo_public`.
|
|
pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
|
|
ensure_org_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await?;
|
|
// Ensure public even if the repo already existed as private (older deployment).
|
|
set_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await
|
|
}
|
|
|
|
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
|
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
|
/// HTTP 204 (already a collaborator) is treated as success.
|
|
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
|
let body = r#"{"permission":"read"}"#;
|
|
let out = Command::new("curl")
|
|
.args([
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"-X",
|
|
"PUT",
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"-H",
|
|
&format!("Authorization: token {core_token}"),
|
|
"-d",
|
|
body,
|
|
&url,
|
|
])
|
|
.output()
|
|
.await
|
|
.context("invoke curl PUT core/meta/collaborators")?;
|
|
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
match code.as_str() {
|
|
"204" => {
|
|
tracing::info!(%name, "forge: granted meta read access");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("PUT core/meta/collaborators/{name} returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
|
/// the agent's proposed config repo so the agent (and the manager) can
|
|
/// fetch the meta flake from the forge. Idempotent: no-op when the
|
|
/// remote already points at the right URL, or when the proposed repo
|
|
/// does not exist yet. No-op when the forge is not running.
|
|
pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
|
if !proposed_dir.join(".git").exists() {
|
|
return Ok(());
|
|
}
|
|
let want = format!("{FORGE_HTTP}/core/meta.git");
|
|
let existing = crate::lifecycle::git_command()
|
|
.current_dir(&proposed_dir)
|
|
.args(["remote", "get-url", "meta"])
|
|
.output()
|
|
.await
|
|
.context("git remote get-url meta")?;
|
|
if existing.status.success() {
|
|
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
|
if current == want {
|
|
return Ok(());
|
|
}
|
|
crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await
|
|
} else {
|
|
crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await
|
|
}
|
|
}
|
|
|
|
/// Mirror agent `name`'s applied config repo — `main` plus every tag
|
|
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
|
|
/// `denied`) — to `agent-configs/<name>` on the local forge.
|
|
/// Best-effort: returns Err which callers log + ignore. No-op when the
|
|
/// forge isn't seeded or the applied repo doesn't exist yet.
|
|
///
|
|
/// Call this after every hive-c0re mutation of an applied repo's refs
|
|
/// so the forge copy always reflects what core actually did. `--force`
|
|
/// because a failed build rolls `main` backwards to the last-good sha.
|
|
///
|
|
/// The tokenised URL is passed straight to `git push` and deliberately
|
|
/// never stored as a named remote: the applied repo is bind-mounted
|
|
/// READ-ONLY into the manager container (`/applied`), so a token in
|
|
/// `.git/config` would leak core's admin credential to an agent.
|
|
pub async fn push_config(name: &str) -> Result<()> {
|
|
let Some(token) = core_token() else {
|
|
return Ok(());
|
|
};
|
|
let dir = Coordinator::agent_applied_dir(name);
|
|
if !dir.join(".git").exists() {
|
|
return Ok(());
|
|
}
|
|
let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git");
|
|
let out = crate::lifecycle::git_command()
|
|
.current_dir(&dir)
|
|
.args([
|
|
"push",
|
|
"--force",
|
|
&url,
|
|
"refs/heads/main:refs/heads/main",
|
|
"refs/tags/*:refs/tags/*",
|
|
])
|
|
.output()
|
|
.await
|
|
.context("invoke git push agent-configs")?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git push {CONFIG_ORG}/{name} failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
|
|
Ok(())
|
|
}
|
|
|
|
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
|
/// HTTP 422 ("user already exists") is treated as success.
|
|
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
|
let body = format!(r#"{{"username":"{name}"}}"#);
|
|
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
|
let status = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
|
match status.as_u16() {
|
|
201 => {
|
|
tracing::info!(%name, "forge: created org");
|
|
Ok(())
|
|
}
|
|
422 | 409 => {
|
|
tracing::debug!(%name, "forge: org already exists");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("POST /api/v1/orgs name={name} returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
|
/// repo creation must never target (#1787) — `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)
|
|
}
|
|
|
|
/// Provision the [`OPERATORS_TEAM`] inside [`AGENTS_ORG`] as an **empty**
|
|
/// team (#1787). Branch protection on agents-org repos references it as the
|
|
/// merge/approval whitelist; the operator adds herself as a member via the
|
|
/// forge UI / hivectl. `includes_all_repositories` so the gate applies to
|
|
/// every agent repo; `write` is enough to approve + merge. hive-c0re never
|
|
/// manages membership. Idempotent (422/409 = already exists).
|
|
async fn ensure_operators_team(token: &str) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/orgs/{AGENTS_ORG}/teams");
|
|
let body = format!(
|
|
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
|
|
);
|
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
|
match status.as_u16() {
|
|
201 => {
|
|
tracing::info!("forge: created {OPERATORS_TEAM} team in {AGENTS_ORG}");
|
|
Ok(())
|
|
}
|
|
409 | 422 => {
|
|
tracing::debug!("forge: {OPERATORS_TEAM} team already exists");
|
|
Ok(())
|
|
}
|
|
other => {
|
|
anyhow::bail!("POST /orgs/{AGENTS_ORG}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add `user` as a collaborator on `owner/repo` at `permission`
|
|
/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a
|
|
/// collaborator / permission updated) both count as success.
|
|
async fn add_collaborator(
|
|
owner: &str,
|
|
repo: &str,
|
|
user: &str,
|
|
permission: &str,
|
|
token: &str,
|
|
) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}");
|
|
let body = format!(r#"{{"permission":"{permission}"}}"#);
|
|
let status = forge_http(reqwest::Method::PUT, &url, token, &body).await?;
|
|
match status.as_u16() {
|
|
201 | 204 => {
|
|
tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set");
|
|
Ok(())
|
|
}
|
|
other => {
|
|
anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply the operator merge-gate branch protection to `repo`'s default
|
|
/// branch (#1787): only [`OPERATORS_TEAM`] members can merge, and an
|
|
/// approving review from that team is required — so the author (a write-level
|
|
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
|
/// rule for the branch (200/409/422) is treated as success.
|
|
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections");
|
|
let body = format!(
|
|
r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"#
|
|
);
|
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
|
match status.as_u16() {
|
|
201 => {
|
|
tracing::info!(%repo, "forge: applied operator branch protection");
|
|
Ok(())
|
|
}
|
|
200 | 409 | 422 => {
|
|
tracing::debug!(%repo, "forge: branch protection already present");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the
|
|
/// #1787 perms: the org owns it (perms stay c0re-managed), the agent is added
|
|
/// as a **write** collaborator (not owner — can push + open PRs but can't
|
|
/// bypass branch protection), and the default branch gets the operator
|
|
/// merge gate. This is the sanctioned create path now that agents can't
|
|
/// create repos directly (`max_repo_creation = 0`). Idempotent.
|
|
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
|
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
|
|
add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?;
|
|
apply_operator_branch_protection(repo, core_token).await?;
|
|
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
|
Ok(format!("{AGENTS_ORG}/{repo}"))
|
|
}
|
|
|
|
/// 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 (#1787): 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");
|
|
}
|
|
}
|
|
// Provision the operator merge-gate team (empty) inside the agents
|
|
// org so branch protection can reference it before anyone joins
|
|
// (#1787). The operator adds herself as a member out-of-band.
|
|
if let Err(e) = ensure_operators_team(token).await {
|
|
tracing::warn!(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;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{CoreTokenCheck, classify_core_token_status};
|
|
use reqwest::StatusCode;
|
|
|
|
#[test]
|
|
fn success_statuses_are_valid() {
|
|
assert_eq!(
|
|
classify_core_token_status(StatusCode::OK),
|
|
CoreTokenCheck::Valid
|
|
);
|
|
assert_eq!(
|
|
classify_core_token_status(StatusCode::NO_CONTENT),
|
|
CoreTokenCheck::Valid
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_rejection_statuses_are_invalid() {
|
|
// The whole point: a stale token (forge rebuilt out from under it)
|
|
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
|
assert_eq!(
|
|
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
|
CoreTokenCheck::Invalid
|
|
);
|
|
assert_eq!(
|
|
classify_core_token_status(StatusCode::FORBIDDEN),
|
|
CoreTokenCheck::Invalid
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transient_and_unexpected_statuses_are_indeterminate() {
|
|
// Never re-mint on a transient — minting needs the forge too, and
|
|
// churning tokens on a blip is worse than keeping the existing one.
|
|
for s in [
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
StatusCode::BAD_GATEWAY,
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
StatusCode::NOT_FOUND,
|
|
] {
|
|
assert_eq!(
|
|
classify_core_token_status(s),
|
|
CoreTokenCheck::Indeterminate,
|
|
"status {s} should be indeterminate"
|
|
);
|
|
}
|
|
}
|
|
}
|