//! 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, PathBuf}; use anyhow::{Context, Result}; use base64::Engine; use reqwest::StatusCode; use tokio::process::Command; use crate::coordinator::Coordinator; const FORGE_CONTAINER: &str = "hive-forge"; 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"; /// Marker that records whether `ensure_core_avatar` has successfully /// uploaded the hyperhive logo as `core`'s avatar. One-shot: the /// upload runs once, the marker is written, subsequent startups skip /// the call. Delete to force re-upload. const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; /// Sibling marker for the `agent-configs` org avatar. Same one-shot /// semantics — delete to force the upload to re-run. const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; /// Per-agent marker written once the account email has been aligned to /// `{name}@hyperhive`. Skips the `PATCH /api/v1/admin/users/{name}` /// call on every subsequent `sync_agent` tick — that PATCH was resetting /// Forgejo's `use_custom_avatar` flag and clobbering the avatar uploaded /// by `forge-avatar-sync`. Delete to force re-alignment. const EMAIL_ALIGNED_MARKER_PREFIX: &str = "/var/lib/hyperhive/forge-email-aligned-"; // 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"; /// 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]; /// 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"; /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/forge-token` from inside the container). fn token_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("forge-token") } /// 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 ` 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 { let mut cmd = Command::new("nixos-container"); // `runuser` (util-linux, always present in a NixOS container) // beats `sudo` here — sudo isn't installed unless `security.sudo` // is enabled, and we don't want to depend on that. // // `--work-path` is mandatory: without it, the admin CLI defaults // WorkPath to `dirname(executable)` (a RO nix-store path), then // looks for `/custom/conf/app.ini` which doesn't // exist, falls back to defaults, and F3 init tries to mkdir // under the nix store and fatals. The systemd unit sets // WORK_PATH for the daemon; we mirror it here for the CLI. cmd.args([ "run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "forgejo", "--work-path", "/var/lib/forgejo", "admin", ]); cmd.args(args); let out = cmd .output() .await .context("invoke nixos-container run hive-forge -- forgejo admin")?; if !out.status.success() { anyhow::bail!( "forgejo admin {} failed ({}): {}", args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim(), ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Pull the access token out of forgejo's success message. Format /// has shifted across versions (table form vs. "Access token was /// successfully created: "), so just hunt the output for the /// first long hex-looking word. fn extract_token(output: &str) -> Option { 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 `, 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 { 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 ` 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 = format!("{EMAIL_ALIGNED_MARKER_PREFIX}{name}"); if std::path::Path::new(&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() => { 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"), } } /// 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 { 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 access token for `name` and persist it to `path` /// (0600). Wraps [`mint_token`] for callers that want the token on /// disk under an agent state dir. async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let token = mint_token(name, 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 token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); crate::lifecycle::chown_to_agent(name, path, "forge"); tracing::info!(%name, path = %path.display(), "forge: persisted 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_token(name, &token_path(name), TOKEN_SCOPES).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//` 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 { 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 = std::path::Path::new(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 = std::path::Path::new(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(()) } /// 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 now, meta-repo push later). Returns the /// token. Idempotent: skips creation when user exists, skips token /// when the file is present. async fn ensure_core_user_and_token() -> Result { 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() { return Ok(trimmed); } } ensure_user_exists("core", true, None).await?; mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).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"}}"#) } /// POST a repo-creation request to `url` and fold "already exists" /// (HTTP 409 / 422) into success. `label` is `/` — 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/`). /// 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 { 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:` or just // any-username:; 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/` 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}" ), } } /// 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/` 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}"), } } /// 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; // 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"); } } /// Sweep every existing container (manager + sub-agents) and ensure /// each has a forgejo user + token, plus an `agent-configs/` /// 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"); } } // 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"); } 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; } }