diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 67e15f9..1e6da8d 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -77,6 +77,11 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { ) .await; drop(guard); + if result.is_ok() + && let Err(e) = crate::forge::ensure_user_for(&agent_bg).await + { + tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed"); + } if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) { tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); } diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs new file mode 100644 index 0000000..e9b7db8 --- /dev/null +++ b/hive-c0re/src/forge.rs @@ -0,0 +1,198 @@ +//! Optional Forgejo wiring. When the `hive-forge` nixos-container is +//! present and running, hive-c0re ensures every agent (and the +//! manager) has a corresponding forgejo user with an API token +//! written to `/forge-token` — visible inside the +//! container as `/state/forge-token`. Idempotent: skips creation +//! when the user already exists, skips token issuance when the file +//! is already there. +//! +//! No-op when `hive-forge` isn't enabled (detected via +//! `nixos-container list`), so operators who don't run the bundled +//! forge pay nothing. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +use crate::coordinator::Coordinator; + +const FORGE_CONTAINER: &str = "hive-forge"; +const TOKEN_NAME_PREFIX: &str = "hyperhive"; +/// Forgejo scopes the agent's token gets. `write:repository` covers +/// clone/push/repo-create on the user's own repos; `write:issue` is +/// what PRs and comments ride under; `read:user` is mandatory for +/// the token-owner endpoint clients use to introspect. +const TOKEN_SCOPES: &str = "read:user,write:repository,write:issue"; + +/// 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. +pub async fn is_present() -> bool { + let Ok(out) = Command::new("nixos-container") + .arg("list") + .output() + .await + else { + return false; + }; + if !out.status.success() { + return false; + } + String::from_utf8_lossy(&out.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. + cmd.args(["run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "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) +} + +/// Ensure a forgejo user named `name` exists. Idempotent: forgejo +/// returns a "user already exists" error which we treat as success. +async fn ensure_user_exists(name: &str) -> Result<()> { + let result = forge_admin(&[ + "user", + "create", + "--username", + name, + "--email", + &format!("{name}@hive.local"), + "--random-password", + "--must-change-password=false", + ]) + .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(()) + } + } + } +} + +/// Mint a fresh access token for `name` and persist it to +/// `/forge-token` (0600). 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. +async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> { + let token_name = format!( + "{TOKEN_NAME_PREFIX}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + let stdout = forge_admin(&[ + "user", + "generate-access-token", + "--username", + name, + "--token-name", + &token_name, + "--scopes", + TOKEN_SCOPES, + ]) + .await?; + let token = extract_token(&stdout) + .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; + 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()))?; + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token"); + Ok(()) +} + +/// Ensure `name` has a forgejo user + token file. No-op when the +/// token file is already present. 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(()); + } + let path = token_path(name); + if path.exists() { + return Ok(()); + } + ensure_user_exists(name).await?; + mint_and_persist_token(name, &path).await +} + +/// Sweep every existing container (manager + sub-agents) and ensure +/// each has a forgejo user + token. Called once at hive-c0re +/// startup. Per-agent 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 Ok(containers) = crate::lifecycle::list().await else { + tracing::warn!("forge: nixos-container list failed; skipping user sweep"); + return; + }; + for c in containers { + let name = if c == crate::lifecycle::MANAGER_NAME { + c + } else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) { + n.to_owned() + } else { + continue; + }; + if let Err(e) = ensure_user_for(&name).await { + tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); + } + } +} diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 8d03682..3ebbabe 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -15,6 +15,7 @@ mod coordinator; mod crash_watch; mod dashboard; mod events_vacuum; +mod forge; mod lifecycle; mod manager_server; mod meta; @@ -134,6 +135,13 @@ async fn main() -> Result<()> { tracing::warn!(error = ?e, "auto-update task failed"); } }); + // Forge user sweep: ensure every existing container has a + // forgejo user + access token. No-op when the hive-forge + // container isn't running. Backgrounded — touches the + // forge state dir via `nixos-container run` which is slow. + tokio::spawn(async move { + forge::ensure_all().await; + }); // Periodic broker vacuum: drop delivered messages older than // 30 days. Undelivered messages are always kept (still in // flight). Runs hourly; first sweep happens immediately.