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

View file

@ -98,9 +98,20 @@ fn last_eight(token: &str) -> Option<&str> {
/// Every token not named [`AGENT_TOKEN_NAME`] is ignored, so the
/// `hyperhive-*` tokens `hive-c0re` left behind never affect the decision.
pub fn classify(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision {
classify_named(AGENT_TOKEN_NAME, AGENT_TOKEN_SCOPES, stored, listed)
}
/// [`classify`] for the token named `name` that should carry `scopes`, so a
/// second kind of token this controller mints decides the same way.
pub(super) fn classify_named(
name: &str,
scopes: &str,
stored: Option<&forge::Credential>,
listed: &[AccessToken],
) -> Decision {
let ours: Vec<&AccessToken> = listed
.iter()
.filter(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME))
.filter(|t| t.name.as_deref() == Some(name))
.collect();
let token = match ours[..] {
[] => return Decision::Mint(MintReason::NotOnForge),
@ -114,7 +125,7 @@ pub fn classify(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> D
return Decision::Mint(MintReason::LastEightMismatch);
}
let listed_scopes = normalized_scopes(token.scopes.iter().flatten().map(String::as_str));
if listed_scopes != normalized_scopes(AGENT_TOKEN_SCOPES.split(',')) {
if listed_scopes != normalized_scopes(scopes.split(',')) {
return Decision::Mint(MintReason::ScopeMismatch);
}
Decision::Keep
@ -216,46 +227,16 @@ impl Client {
Decision::Mint(reason) => reason,
};
if listed
.iter()
.any(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME))
{
match self
.api
.admin_delete_user_access_token(agent, AGENT_TOKEN_NAME)
.await
{
Ok(()) => {}
Err(e) if is_not_found(&e) => {}
Err(e) => {
return Err(e)
.with_context(|| format!("deleting {agent}'s {AGENT_TOKEN_NAME} token"));
}
}
}
let created = self
.api
.admin_create_user_access_token(
let credential = self
.replace_token(
&store,
agent,
CreateAccessTokenOption {
name: AGENT_TOKEN_NAME.to_owned(),
repositories: None,
scopes: Some(AGENT_TOKEN_SCOPES.split(',').map(str::to_owned).collect()),
},
AGENT_TOKEN_NAME,
AGENT_TOKEN_SCOPES,
&listed,
&path,
)
.await
.with_context(|| format!("creating {agent}'s {AGENT_TOKEN_NAME} token"))?;
let Some(value) = created.sha1.filter(|v| !v.is_empty()) else {
bail!("the forge created {agent}'s token but returned no value for it");
};
let credential = forge::Credential {
value,
name: AGENT_TOKEN_NAME.to_owned(),
};
store
.write(&path, &credential)
.await
.with_context(|| format!("storing {agent}'s forge token at {path}"))?;
.await?;
let as_agent = Forgejo::new(Auth::Token(&credential.value), self.url.clone())
.context("building a forge client with the new token")?;
@ -270,6 +251,57 @@ impl Client {
Ok(())
}
/// Delete `user`'s token named `name` if `listed` has one, create a new
/// one carrying `scopes`, and store it at `path`. A crash between any two
/// steps leaves a state [`classify_named`] reads as a mint again.
///
/// # Errors
/// When the forge refuses the delete or the create, answers the create
/// with no value, or the store refuses the write.
pub(super) async fn replace_token(
&self,
store: &swarm_secret_client::SecretStore,
user: &str,
name: &str,
scopes: &str,
listed: &[AccessToken],
path: &str,
) -> Result<forge::Credential> {
if listed.iter().any(|t| t.name.as_deref() == Some(name)) {
match self.api.admin_delete_user_access_token(user, name).await {
Ok(()) => {}
Err(e) if is_not_found(&e) => {}
Err(e) => {
return Err(e).with_context(|| format!("deleting {user}'s {name} token"));
}
}
}
let created = self
.api
.admin_create_user_access_token(
user,
CreateAccessTokenOption {
name: name.to_owned(),
repositories: None,
scopes: Some(scopes.split(',').map(str::to_owned).collect()),
},
)
.await
.with_context(|| format!("creating {user}'s {name} token"))?;
let Some(value) = created.sha1.filter(|v| !v.is_empty()) else {
bail!("the forge created {user}'s token but returned no value for it");
};
let credential = forge::Credential {
value,
name: name.to_owned(),
};
store
.write(path, &credential)
.await
.with_context(|| format!("storing {user}'s forge token at {path}"))?;
Ok(credential)
}
/// What the forge and the store say about `agent`'s token.
pub(super) async fn observe_agent(
&self,