hive-c0re: stop minting agent forge tokens

Delete ensure_user_for and mint_and_persist_agent_token, the user step
of sync_agent (the per-rebuild re-mint, #4644) and of
forge_after_first_spawn, and the hive-priv WriteAgentForgeToken request
that wrote the token into the agent's state dir. hivectl forge
create-user now refuses an agent and points at swarmctl agent
mint-forge-token. mint_token, ensure_user_exists and TOKEN_SCOPES stay:
provision_user_token and the core bootstrap still call them.

Refs #3782
This commit is contained in:
atlas 2026-09-24 16:39:45 +02:00 • committed by mara
commit b5d07d4df2
10 changed files with 59 additions and 126 deletions

View file

@ -527,7 +527,7 @@ pub(crate) async fn resolve_approval_dag(
let mut terminal_tag = None;
match approval.kind {
ApprovalKind::Spawn => {
// Post-spawn forge bookkeeping (user, config repo mirror, meta
// Post-spawn forge bookkeeping (config repo mirror, meta
// access) — warn-only, then the resolution events + a rescan so
// the dashboard reflects the post-spawn state either way.
if result.is_ok() {
@ -605,13 +605,10 @@ fn fetch_approval_for_worker(
}
/// Forge bookkeeping run once after the very first container spawn:
/// create the per-agent forge user, mirror the applied repo, and grant
/// read access to core/meta. Also rescans containers so the dashboard
/// reflects the post-spawn state.
/// mirror the applied repo and grant read access to core/meta. The
/// agent's forge user and token are swarm-controller's, not this
/// hive's. Also rescans containers so the dashboard reflects the post-spawn state.
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
if let Err(e) = crate::forge::ensure_user_for(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed");
}
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
}

View file

@ -1,4 +1,4 @@
//! Optional Forgejo wiring — per-agent user + token provisioning,
//! Optional Forgejo wiring — per-agent account alignment,
//! 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.
@ -21,7 +21,7 @@ pub use repos::{
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main,
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
};
pub use users::{core_token, ensure_user_for, provision_user_token};
pub use users::{core_token, provision_user_token};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
@ -212,9 +212,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
}
/// 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
/// Per-agent forge sync: align the agent's forge account (email,
/// repo-creation lockdown), mirror its config repo, grant read access
/// to `core/meta`, and wire 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
@ -228,11 +228,10 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
// The agent's forge user and token are swarm-controller's now
// (`MintAgentForgeToken`); this hive no longer creates or re-mints
// either.
let mut ok = true;
if let Err(e) = ensure_user_for(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
ok = false;
}
// 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).

View file

@ -313,8 +313,8 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
/// 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.
/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and
/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user.
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
@ -349,16 +349,6 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
Ok(token)
}
/// Mint a fresh Forgejo access token for an agent and write it to the
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
/// cannot write to agent-owned (0755) state directories directly.
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
let token = mint_token(name, TOKEN_SCOPES).await?;
crate::priv_client::write_agent_forge_token(name, &token)
.await
.with_context(|| format!("write forge-token for {name} via hive-priv"))
}
/// Mint a fresh Forgejo access token for the `core` admin user and
/// write it directly to `path`. Unlike agent tokens this path is owned
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
@ -376,24 +366,11 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
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_agent_token(name).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/<name>/` directories for users that
/// aren't agents.
/// token, which is **not** persisted to disk — the caller is responsible
/// for storing it. Used by `hivectl forge create-user` for human
/// (non-agent) accounts. Agent tokens are minted by swarm-controller
/// (`swarm-controller/src/forge/agent_token.rs`), not here.
///
/// `password` picks the account password. `None` keeps the existing
/// random-throwaway shape (caller doesn't need web UI access — token

View file

@ -361,18 +361,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?)
}
/// Write the Forgejo access token for `agent_name` to
/// `<agent_state_root>/<agent_name>/state/forge-token` via hive-priv
/// (running as root). The file is written 0600 and chowned to the agent
/// user so it is readable from inside the agent container.
pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> {
ok(call(&PrivRequest::WriteAgentForgeToken {
agent_name: agent_name.to_owned(),
token: token.to_owned(),
})
.await?)
}
/// Write a Matrix access token for `agent_name` via hive-priv (running as
/// root). `account: None` writes the hive-internal
/// `<state>/matrix-token`; `account: Some(name)` writes

View file

@ -600,36 +600,30 @@ async fn handle_forge_create_user(
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
);
}
let mut out = Vec::new();
if agent_exists(name)? {
if password.is_some() {
// Agents authenticate by API token, never by password — refuse
// rather than silently dropping a supplied one.
anyhow::bail!(
"forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
);
}
crate::forge::ensure_user_for(name.as_str())
.await
.with_context(|| format!("forge create-user {name}"))?;
let path = Coordinator::agent_notes_dir(name).join("forge-token");
out.push(format!("forge: provisioned agent user '{name}'"));
out.push(format!("token persisted at: {}", path.display()));
// An agent's forge user and token are swarm-controller's: it mints
// the token into the swarm secret store and the agent fetches it
// from there. A hive-minted token would be one the swarm neither
// tracks nor rotates.
anyhow::bail!(
"forge create-user: '{name}' is an agent; its forge token is minted by \
swarm-controller — run `swarmctl agent mint-forge-token {name}` on the \
controller host"
);
}
let token = crate::forge::provision_user_token(name.as_str(), password)
.await
.with_context(|| format!("forge create-user {name}"))?;
let mut out = vec![
format!("forge: provisioned user '{name}' (not an agent — token not persisted)"),
format!("token: {token}"),
];
if password.is_some() {
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
} else {
let token = crate::forge::provision_user_token(name.as_str(), password)
.await
.with_context(|| format!("forge create-user {name}"))?;
out.push(format!(
"forge: provisioned user '{name}' (not an agent — token not persisted)"
));
out.push(format!("token: {token}"));
if password.is_some() {
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
} else {
out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
);
}
out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
);
}
Ok(HostResponse::messages(out))
}