Watch
0
0
Fork
You've already forked hyperhive
0

hives read the forge through their own swarm-minted read account

swarm-controller now gives every hive in its directory a forge account,
`hive-<hive>`, in a new `hives` team of `agent-configs` (read on
`repo.code` + `repo.pulls`, every repo including later ones), and mints
one `read:repository` token named `swarm-hive` into the store at
`swarm/hives/<hive>/forge/token`. A five-minute pass queues a
`MintHiveForgeToken` node for a hive whose token is missing or no longer
matches the forge, whose account is missing, or whose team membership or
team settings drifted. A hive whose store or forge read failed is never
queued, so an outage cannot rotate every hive's token. The token decision
reuses `agent_token`'s classifier and replace step, now parameterised by
name and scopes.

An agent may be called `hive-<x>`. The hive account is created with
`hive-<hive>@hives.hyperhive.local` and the job refuses an account by that
name with any other email before it locks it down, adds it to the team or
mints for it; agent creation already refuses the hive's account by the
same email check.

The controller's bao policy gains the `swarm/hives/+/forge/token` leaf
(create/read/update), pinned by the bao-grants module-eval assertion. The
hive's own policy already reads `swarm/hives/<hive>/*`.

hive-c0re reads that token through `forge::reader_token()`, falling back
to the core token file when the store holds none, and uses it for:
fetching a merged config rev (swarm deploy, `hivectl forge
reconcile-config`), reseeding a purged `applied`, the meta-update
cascade's fast-forward, seeding `proposed` on first deploy, and finding
the PR that merged a refused rev. Those reads no longer require a local
`hive-forge` container. The `/knowledge` clone moves from the forge
host's boot sweep into the knowledge pull, so every hive clones it.

Posting the deploy-failure comment on that PR stays on the core token.

Refs #4851
This commit is contained in:
atlas 2026-10-07 20:10:41 +02:00
commit 21e3f626f4
17 changed files with 1222 additions and 138 deletions

View file

@ -1,15 +1,18 @@
//! Optional Forgejo wiring — the `core/meta` repo, meta read-access
//! grants, and each agent's read-only grant on `internal/docs` (the
//! swarm-controller creates that repo, with the other swarm-wide orgs,
//! teams and repos).
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
//! teams and repos). Those writes, as `core`, run only beside a local
//! `hive-forge` container; reads of the forge go through [`reader_token`] on
//! every hive. Full design: `docs/integrations/forge.md`.
mod pr_comment;
mod reader;
mod reconcile;
mod repos;
mod users;
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
@ -71,14 +74,20 @@ fn git_url_with_base(base: &str, repo: &str) -> String {
}
/// The `http.extraHeader` value authenticating as the forge core user.
pub(crate) fn core_auth_header(token: &str) -> String {
basic_auth_header("core", token)
}
/// The `http.extraHeader` value carrying `token`. Forgejo takes the account
/// from the token, so `login` only labels the request.
///
/// Basic auth over a header rather than userinfo in the URL, so the secret
/// reaches git through the environment (see [`forge_git_url`]). Pair with
/// [`crate::lifecycle::git_command_authed`], which is the only thing that
/// should ever hold the result.
pub(crate) fn core_auth_header(token: &str) -> String {
fn basic_auth_header(login: &str, token: &str) -> String {
use base64::Engine as _;
let basic = base64::engine::general_purpose::STANDARD.encode(format!("core:{token}"));
let basic = base64::engine::general_purpose::STANDARD.encode(format!("{login}:{token}"));
format!("Authorization: Basic {basic}")
}
@ -223,8 +232,7 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
}
/// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the
/// local knowledge clone, the core avatar, and removing this hive's own
/// config-PR hook —
/// core avatar, and removing this hive's own config-PR hook —
/// every step that needs an authenticated forge client. The swarm-wide
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
/// `internal/knowledge`, the `agent-configs` avatar) are the
@ -243,16 +251,6 @@ async fn ensure_all_orgs_and_repos(token: &str) {
format!("forge: core/meta repo provisioning failed: {e}"),
);
}
// Clone knowledge repo locally so it can be bind-mounted into agents.
// The swarm-controller creates and seeds the repo itself.
if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
crate::warnings::set_boot_warning(
"forge_knowledge_local_clone",
"crit",
format!("knowledge: local clone failed: {e}"),
);
}
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
crate::warnings::set_boot_warning(

View file

@ -1,10 +1,10 @@
//! Commenting on an agent's config PRs as the core forge user — how a hive
//! reports a merged config commit it refused to deploy back onto the PR that
//! merged it.
//! merged it. Finding that PR only reads, so it uses [`reader_token`].
use anyhow::{Context, Result};
use super::{CONFIG_ORG, api, core_token};
use super::{CONFIG_ORG, api, core_token, reader_token};
/// Full `owner/name` path of an agent's config repo on the forge.
pub fn config_repo(agent: &str) -> String {
@ -14,13 +14,13 @@ pub fn config_repo(agent: &str) -> String {
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
///
/// # Errors
/// Absent core token, malformed repo, no such PR, or transport/API failure.
/// No read token, malformed repo, no such PR, or transport/API failure.
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64> {
let token = core_token().context("forge core token absent")?;
let reader = reader_token().await.context("no forge read token")?;
let (owner, name) = repo
.split_once('/')
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
let pull = api(&token)?
let pull = api(reader.value())?
.repo_get_commit_pull_request(owner, name, sha)
.await
.context("GET pull request of commit")?;

View file

@ -0,0 +1,159 @@
//! The credential this hive reads the forge with: its own read-only account,
//! whose token `swarm-controller` mints into the store at
//! `swarm/hives/<hive>/forge/token`, and the core token file where the store
//! holds none.
//!
//! The file is the fallback so a hive deployed before the controller mints its
//! token keeps the reads it had: the forge host holds the file, no other hive
//! does.
use super::{basic_auth_header, core_token};
/// The basic-auth user [`ReaderToken::auth_header`] names for the core token.
const CORE_LOGIN: &str = "core";
/// A forge token and the account it belongs to.
///
/// No `Debug` derive: `value` is a live forge credential.
pub struct ReaderToken {
login: String,
value: String,
}
impl std::fmt::Debug for ReaderToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReaderToken")
.field("login", &self.login)
.field("value", &"<redacted>")
.finish()
}
}
impl ReaderToken {
/// The token itself, for an API client.
pub fn value(&self) -> &str {
&self.value
}
/// The `http.extraHeader` value for git; see [`basic_auth_header`].
pub fn auth_header(&self) -> String {
basic_auth_header(&self.login, &self.value)
}
}
/// The token to read the forge with, or `None` when this hive has neither.
///
/// 🩸 Logs the store path and which source won, never a value.
pub async fn reader_token() -> Option<ReaderToken> {
pick(stored_reader_token().await, core_token())
}
/// The store's token wins over the core file's.
fn pick(stored: Option<ReaderToken>, core: Option<String>) -> Option<ReaderToken> {
let picked = stored.or_else(|| {
core.map(|value| ReaderToken {
login: CORE_LOGIN.to_owned(),
value,
})
});
if let Some(t) = &picked {
tracing::debug!(login = %t.login, "forge: reading as this account");
} else {
tracing::debug!("forge: no read token in the store or the core token file");
}
picked
}
/// This hive's token from the store, under the hive's own identity.
///
/// `None`, never an error, for every way it can come up empty — no hive name,
/// no `BAO_*` identity, an unreachable store, nothing at the path — the same
/// as `matrix::stored_sender_token`, and for the same reason: each sends the
/// caller to the fallback.
async fn stored_reader_token() -> Option<ReaderToken> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())?;
let path = match swarm_secret_client::forge::hive_token_path(&hive) {
Ok(path) => path,
Err(e) => {
tracing::warn!(error = %e, "forge: this hive's name does not form a store path");
return None;
}
};
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store,
Err(e) => {
tracing::debug!(error = %e, "forge: no swarm secret store to read the hive token from");
return None;
}
};
match store
.read::<swarm_secret_client::forge::Credential>(&path)
.await
{
Ok(credential) if !credential.value.trim().is_empty() => Some(ReaderToken {
login: swarm_secret_client::forge::hive_login(&hive),
value: credential.value.trim().to_owned(),
}),
Ok(_) => {
tracing::warn!(%path, "forge: the stored hive token is empty");
None
}
Err(e) => {
tracing::debug!(%path, error = %e, "forge: no hive token in the store");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stored() -> ReaderToken {
ReaderToken {
login: "hive-alpha".to_owned(),
value: "from-the-store".to_owned(),
}
}
#[test]
fn the_store_wins_over_the_core_file() {
let t = pick(Some(stored()), Some("from-the-file".to_owned())).expect("a token");
assert_eq!(t.value(), "from-the-store");
assert_eq!(t.login, "hive-alpha");
}
#[test]
fn the_core_file_is_the_fallback() {
let t = pick(None, Some("from-the-file".to_owned())).expect("a token");
assert_eq!(t.value(), "from-the-file");
assert_eq!(t.login, CORE_LOGIN);
}
#[test]
fn neither_is_none() {
assert!(pick(None, None).is_none());
}
#[test]
fn the_header_names_the_tokens_own_account() {
use base64::Engine as _;
let header = stored().auth_header();
let encoded = header
.strip_prefix("Authorization: Basic ")
.expect("a basic auth header");
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("base64");
assert_eq!(decoded, b"hive-alpha:from-the-store");
}
#[test]
fn debug_never_prints_the_value() {
let shown = format!("{:?}", stored());
assert!(!shown.contains("from-the-store"), "{shown}");
assert!(shown.contains("hive-alpha"), "{shown}");
}
}

View file

@ -17,7 +17,7 @@ use anyhow::{Context, Result};
use hive_host_sock::{HostResponse, ReconcileDirection};
use super::{CONFIG_ORG, core_auth_header, core_token, forge_git_url, is_present};
use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Scratch ref the forge `main` is fetched into — outside the normal
/// branch/tag namespace so it never collides with real refs.
@ -27,11 +27,11 @@ const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
/// ref (read-only, no working-tree change) and return the applied dir.
/// Also how a swarm deploy of a merged commit gets that commit's objects.
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
if !is_present().await {
anyhow::bail!("forge is not running");
}
let Some(token) = core_token() else {
anyhow::bail!("forge core token not available");
let Some(reader) = reader_token().await else {
anyhow::bail!(
"no forge read token: the swarm store holds none for this hive and there is no core \
token file"
);
};
let dir = crate::paths::applied_dir(agent);
if !dir.join(".git").exists() {
@ -41,7 +41,7 @@ pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
crate::lifecycle::git_authed(
&dir,
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
&core_auth_header(&token),
&reader.auth_header(),
)
.await
.context("fetch forge config main (does forge main exist yet?)")?;

View file

@ -17,7 +17,7 @@ use crate::coordinator::Coordinator;
use super::{
CONFIG_ORG, SHARED_DOCS_REPO, SHARED_ORG, api, core_auth_header, core_token, forge_git_url,
forge_http_base, is_present,
forge_http_base, is_present, reader_token,
};
/// Creation options for an empty repo defaulting to `main`.
@ -223,17 +223,14 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
/// snapshot. A config deployed through hive-c0re without a forge merge never
/// reaches it.
///
/// Best-effort: returns `false` (not an error) rather than bailing when the
/// forge is absent, the core token isn't minted yet, `applied` already has a
/// `.git` (nothing to do), or any git step fails — callers fall back to a
/// less-current source or the original hard error. Only `true` once `applied` is
/// Best-effort: returns `false` (not an error) rather than bailing when there
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), or
/// any git step fails — callers fall back to a less-current source or the
/// original hard error. Only `true` once `applied` is
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
/// `setup_applied` seed shape exactly.
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
if !is_present().await {
return false;
}
let Some(token) = core_token() else {
let Some(reader) = reader_token().await else {
return false;
};
let applied = crate::paths::applied_dir(name);
@ -249,7 +246,7 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
return false;
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = core_auth_header(&token);
let auth = reader.auth_header();
let out = crate::lifecycle::git_command_authed(&auth)
.current_dir(&applied)
.args([
@ -298,14 +295,11 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
/// ancestry-gated advance for a reviewed PR merge, which this is not.
///
/// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns
/// `false` (not an error) when the forge is absent, the core token isn't
/// minted, `applied` has no `.git` yet (nothing to fast-forward — reseed
/// instead), or any git step fails.
/// `false` (not an error) when there is no [`reader_token`], `applied` has
/// no `.git` yet (nothing to fast-forward — reseed instead), or any git step
/// fails.
pub async fn fast_forward_applied_main(name: &str) -> bool {
if !is_present().await {
return false;
}
let Some(token) = core_token() else {
let Some(reader) = reader_token().await else {
return false;
};
let applied = crate::paths::applied_dir(name);
@ -313,7 +307,7 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
return false;
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = core_auth_header(&token);
let auth = reader.auth_header();
let out = crate::lifecycle::git_command_authed(&auth)
.current_dir(&applied)
.args(["fetch", "--no-tags", &url, "refs/heads/main"])
@ -356,8 +350,8 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
/// is the fallback, not the default.
///
/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an
/// ordinary answer rather than an error: the forge is absent, the core token
/// is not minted yet, or `agent-configs/<name>` has no `main` — the last of
/// ordinary answer rather than an error: there is no [`reader_token`], the
/// forge is unreachable, or `agent-configs/<name>` has no `main` — the last of
/// which is the *normal* case right after `swarm-controller` creates the
/// agent, before it has pushed anything into the repo it just made.
///
@ -366,17 +360,14 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
/// `.git` behind either — so the caller's "does this repo already exist"
/// check reads exactly the same before and after a failed attempt.
pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
if !is_present().await {
return false;
}
let Some(token) = core_token() else {
let Some(reader) = reader_token().await else {
return false;
};
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
// `--branch main` fails outright against a repo with no commits, so an
// empty repo reads as "nothing to take" instead of cloning to an unborn
// HEAD that would then look like a seeded checkout.
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
let out = crate::lifecycle::git_command_authed(&reader.auth_header())
.args([
"clone",
"--branch",

View file

@ -27,7 +27,7 @@ use super::git::{
/// there's genuinely nothing to clone yet — `swarm-controller` creates
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't
/// necessarily pushed `main` to it yet, or the forge is unreachable, or
/// the core token isn't minted.
/// this hive has no forge read token.
///
/// **Seeding is the whole of hive-c0re's write.** Changes to the repo
/// arrive as PRs from a clone, via the forge, like any other code

View file

@ -22,7 +22,7 @@
use anyhow::{Context, Result};
use crate::coordinator::Coordinator;
use crate::forge::{core_auth_header, forge_git_url};
use crate::forge::forge_git_url;
pub const ORG: &str = "internal";
pub const REPO: &str = "knowledge";
@ -38,14 +38,12 @@ pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
pub const CONTAINER_MOUNT: &str = "/knowledge";
/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
/// repository. `core_token` authenticates the HTTPS clone so private repos
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
/// repository, authenticated with `auth` (an `http.extraHeader` value) when
/// given. Idempotent — skips if `LOCAL_DIR/.git` exists.
///
/// The swarm controller seeds a README into a freshly created repo. A clone
/// taken before that lands is empty; the next [`pull`] fills it.
///
/// Called once at hive-c0re startup after `forge::ensure_all`.
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
async fn ensure_local_clone(auth: Option<&str>) -> Result<()> {
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
if git_dir.exists() {
tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}");
@ -53,7 +51,11 @@ pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
}
std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?;
let url = forge_git_url(&format!("{ORG}/{REPO}"));
let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token))
let mut cmd = match auth {
Some(header) => crate::lifecycle::git_command_authed(header),
None => crate::lifecycle::git_command(),
};
let out = cmd
.args(["clone", &url, LOCAL_DIR])
.output()
.await
@ -86,15 +88,21 @@ async fn head_sha() -> Option<String> {
/// `--ff-only` so a force-push to the knowledge repo never wedges the local
/// copy silently.
///
/// When there is no clone yet, takes one instead ([`ensure_local_clone`]),
/// so every hive gets `/knowledge` from its first pull.
///
/// When the pull actually moves `HEAD` (a real change, not a no-op),
/// broadcasts a short `git diff --stat` summary to every live agent's
/// inbox via `coord` — inbox-only, no forced wake.
pub async fn pull(coord: &Coordinator) -> Result<()> {
// Sanity: if the clone is missing (e.g. storage was wiped), refuse
// to pull and let the caller decide whether to re-clone.
// The repo is public (the swarm controller makes it so), so an
// unauthenticated clone or pull is enough — but authenticate when a token
// is around, which keeps this working if the repo is ever made private
// again.
let auth = crate::forge::reader_token().await.map(|t| t.auth_header());
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
if !git_dir.exists() {
anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first");
return ensure_local_clone(auth.as_deref()).await;
}
// Rewrite any credentialed `origin` left by an older clone, which spliced
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
@ -129,10 +137,6 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
.await;
let before = head_sha().await;
// The repo is public (the swarm controller makes it so), so an
// unauthenticated pull is enough — but authenticate when a token is around,
// which keeps this working if the repo is ever made private again.
let auth = crate::forge::core_token().map(|t| core_auth_header(&t));
let mut cmd = match &auth {
Some(header) => crate::lifecycle::git_command_authed(header),
None => crate::lifecycle::git_command(),