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))
}

View file

@ -316,10 +316,10 @@ pub enum HostRequest {
room: Option<String>,
},
/// Create or refresh a forge account + API token for `name`. Daemon-side
/// equivalent of `hivectl forge create-user`: for an existing agent it
/// provisions the account and persists the token to `<notes>/forge-token`;
/// for a non-agent (operator/human) it mints a user and returns the token
/// in [`HostResponse::messages`]. `password` is resolved client-side
/// equivalent of `hivectl forge create-user`: for a non-agent
/// (operator/human) it mints a user and returns the token in
/// [`HostResponse::messages`]. An existing agent is refused: its token is
/// swarm-controller's to mint. `password` is resolved client-side
/// (inline flag or stdin) and only meaningful for non-agent accounts.
ForgeCreateUser {
name: Ident,

View file

@ -468,19 +468,6 @@ pub enum PrivRequest {
},
// --- Agent credential writes ---
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/state/forge-token`.
///
/// hive-priv validates `agent_name`, creates the state dir if absent,
/// writes the file 0600, and chowns it to the state dir's owner so
/// the agent process can read it. Required because hive-c0re runs
/// unprivileged and cannot write to agent-owned state directories.
WriteAgentForgeToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
},
/// Write a matrix access token into the agent's state dir. With
/// `account: None` it targets the hive-internal `matrix-token`; with
/// `account: Some(name)` it targets `matrix-token-<name>` for an extra
@ -488,8 +475,10 @@ pub enum PrivRequest {
/// `account` suffix as plain identifiers before building the path, so a
/// crafted account name cannot traverse out of the state dir.
///
/// Same write semantics as `WriteAgentForgeToken` — validates names,
/// creates dir, writes 0600, chowns to agent owner.
/// hive-priv validates the names, creates the state dir if absent,
/// writes the file 0600, and chowns it to the state dir's owner so the
/// agent process can read it. Required because hive-c0re runs
/// unprivileged and cannot write to agent-owned state directories.
WriteAgentMatrixToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
@ -512,7 +501,7 @@ pub enum PrivRequest {
/// The operator-supplied GitHub personal access token (PAT) for the
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`). Same write
/// semantics as
/// `WriteAgentForgeToken` — validates `agent_name`, creates the state dir
/// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir
/// if absent, writes the file 0600, and chowns it to the agent so the
/// `gh` wrapper / git credential helper can read it. No account suffix
/// (single GitHub account per agent).
@ -534,7 +523,7 @@ pub enum PrivRequest {
/// `label` MUST be validated as a plain identifier (same rule as the
/// matrix `account` suffix) before it goes into the filename — a
/// crafted label could otherwise traverse out of the state dir. Same
/// write semantics as `WriteAgentForgeToken` — validates `agent_name`,
/// write semantics as `WriteAgentMatrixToken` — validates `agent_name`,
/// creates the state dir if absent, writes both files 0600, chowns to
/// the agent.
WriteAgentExtraForgeAccount {

View file

@ -403,11 +403,6 @@ async fn exec(
paused,
} => exec_set_agent_paused(agent_name, paused),
PrivRequest::WriteAgentForgeToken {
ref agent_name,
ref token,
} => write_forge_token(agent_name, token),
PrivRequest::WriteAgentMatrixToken {
ref agent_name,
ref token,
@ -619,12 +614,6 @@ fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, Stri
set_agent_paused(agent_name, paused)
}
/// `WriteAgentForgeToken`.
fn write_forge_token(agent_name: &str, token: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
write_agent_state_file(agent_name, "forge-token", &format!("{token}\n"))
}
/// `WriteAgentGithubToken`.
fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
@ -1441,7 +1430,7 @@ struct ForgeSidecar<'a> {
base_url: &'a str,
}
/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`.
/// Shared helper for the `WriteAgent*Token` requests.
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
/// chowns to the agent user (derived from the state dir's existing owner),
/// and chmods 0600. Running as root (hive-priv), so this succeeds

View file

@ -248,15 +248,15 @@ impl ScopeArgs {
#[derive(Subcommand)]
pub enum ForgeCmd {
/// Create or refresh the Forgejo account + token for `<name>`.
/// Create or refresh a non-agent Forgejo account + token for `<name>`.
///
/// For an existing agent, persists the token to its state dir; for a
/// human/other account, prints the token to stdout. Set a password to
/// enable forge web-UI login (otherwise it uses a random throwaway).
/// Prints the token to stdout. Set a password to enable forge web-UI
/// login (otherwise it uses a random throwaway). Refused for an
/// existing agent: swarm-controller mints an agent's token
/// (`swarmctl agent mint-forge-token <agent>`).
CreateUser {
/// Forgejo username. For agents: the container/agent name
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
/// For humans: any forgejo username — `mara`, `damocles`, etc.
/// Forgejo username of a human/other account — `mara`,
/// `damocles`, etc.
name: String,
/// Set the account password to this string instead of a random
/// throwaway. Use this for operator accounts that need to log

View file

@ -1,5 +1,5 @@
//! `hivectl forge create-user <name>` — provision a Forgejo account via the
//! daemon (which owns the forge admin token + account-token persistence);
//! daemon (which owns the forge admin token);
//! hivectl just resolves the password client-side and relays the request.
use std::io::{self, Write};