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

@ -34,6 +34,7 @@ use utoipa::ToSchema;
use crate::webhook::DeliveryKind;
pub mod agent_token;
pub mod hive_token;
pub mod legacy_tokens;
pub mod objects;
pub mod runner_token;
@ -455,13 +456,20 @@ impl Client {
/// Create `agent`'s account. Idempotent: an existing user (409/422) is
/// folded into success, same as [`Self::ensure_org_repo`].
async fn create_agent_user(&self, agent: &str) -> Result<()> {
self.create_user(agent, &agent_email(agent)).await
}
/// Create the account `login` with `email` and a throwaway password.
/// Idempotent like [`Self::create_agent_user`], and like it proves only
/// that *some* account named `login` exists: the caller checks the email.
async fn create_user(&self, login: &str, email: &str) -> Result<()> {
let password = crate::webhook::generate_hex_secret()
.context("generating a throwaway password for the agent's forge account")?;
.context("generating a throwaway password for a forge account")?;
let res = self
.api
.admin_create_user(CreateUserOption {
created_at: None,
email: agent_email(agent),
email: email.to_owned(),
full_name: None,
login_name: None,
must_change_password: Some(false),
@ -469,24 +477,24 @@ impl Client {
restricted: None,
send_notify: None,
source_id: None,
username: agent.to_owned(),
username: login.to_owned(),
visibility: None,
})
.await;
match res {
Ok(_) => {
tracing::info!(%agent, "swarm forge: created agent forge user");
tracing::info!(%login, "swarm forge: created forge user");
Ok(())
}
Err(e) => {
let existing =
is_ambiguous_validation_failure(&e) && self.api.user_get(agent).await.is_ok();
is_ambiguous_validation_failure(&e) && self.api.user_get(login).await.is_ok();
if folds_into_success(&e, existing) {
tracing::debug!(%agent, "swarm forge: agent forge user already exists");
tracing::debug!(%login, "swarm forge: forge user already exists");
Ok(())
} else {
tracing::error!(%agent, error = %e, "swarm forge: forge refused to create agent user");
Err(e).with_context(|| format!("create forge user {agent}"))
tracing::error!(%login, error = %e, "swarm forge: forge refused to create user");
Err(e).with_context(|| format!("create forge user {login}"))
}
}
}
@ -1426,13 +1434,14 @@ mod tests {
}
/// What [`stub_forge`] saw: method, path and JSON body of each request.
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
pub(super) type Seen =
std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
/// A stand-in forge on a loopback port — never the real one — answering
/// the admin user create (`POST`) with `create`, the pre-lockdown
/// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with
/// `edit` (status, JSON body each), and recording every request.
async fn stub_forge(
pub(super) async fn stub_forge(
create: (u16, &'static str),
get: (u16, &'static str),
edit: (u16, &'static str),
@ -1470,7 +1479,7 @@ mod tests {
}
/// The `PATCH` [`Client::ensure_agent_user`] sent, if any.
fn lockdown_patch(seen: &Seen, agent: &str) -> Option<serde_json::Value> {
pub(super) fn lockdown_patch(seen: &Seen, agent: &str) -> Option<serde_json::Value> {
let path = format!("/api/v1/admin/users/{agent}");
seen.lock()
.unwrap()