swarm-controller: accept the legacy pre-alignment email in the lockdown guard

disable_repo_creation's ownership guard now matches either the aligned
{agent}@hyperhive.local email or the legacy {agent}@hive.local one
hive-c0re::forge::users::ensure_user_exists used before its own
ensure_user_email alignment pass existed. That pass only runs on the
forge-host hive, and only once it has a core token and has ticked, so a
real pre-existing agent can still carry the old email when this node reads
it. Without this, the guard would bail "not this agent's" on a genuine
agent during that rollout window (argus, round 2).

Verified separately (not a code change): swarm-controller's forge account
is a site admin (created with --admin), and Forgejo's
convert.toUser/ToUser only hides an account's email when the caller isn't
the admin and isn't the account itself (services/convert/user.go), so
user_get already returns the real email regardless of hide_email on the
target account. No endpoint change needed for that half of the review.
This commit is contained in:
atlas 2026-09-24 15:40:18 +02:00 • committed by mara
commit 2fda529ca8

View file

@ -408,11 +408,17 @@ impl Client {
/// a 422's follow-up `user_get` only confirms the username, not that
/// swarm-controller created it — so before that PATCH this reads the
/// account once and checks the one thing that *is* comparable: the
/// email `create_agent_user` sets at creation (see [`agent_email`]),
/// the same marker `hive-c0re`'s legacy `ensure_user_exists` /
/// `ensure_user_email` set and converge to. A mismatch means the name
/// collided with an account this node didn't create, so it fails the
/// node rather than sending the PATCH (or silently skipping) to an
/// email. A match against either [`agent_email`] (what
/// `create_agent_user` itself sets) or [`legacy_agent_email`] (what
/// `hive-c0re::forge::users::ensure_user_exists` used before its own
/// `ensure_user_email` alignment pass runs) counts as this agent's
/// account — the legacy value has to be accepted too, not just the
/// aligned one, because `ensure_user_email` only runs on the forge-host
/// hive and only once it has a core token and has ticked at least once,
/// so a real, existing legacy agent can still carry the old email when
/// this node reads it mid-rollout. A mismatch against both means the
/// name collided with an account this node didn't create, so it fails
/// the node rather than sending the PATCH (or silently skipping) to an
/// account nobody verified is agent-controlled.
async fn disable_repo_creation(&self, agent: &str) -> Result<()> {
let user = self
@ -421,10 +427,11 @@ impl Client {
.await
.with_context(|| format!("read forge user {agent} before locking it down"))?;
let expected = agent_email(agent);
if user.email.as_deref() != Some(expected.as_str()) {
let legacy = legacy_agent_email(agent);
if !matches!(user.email.as_deref(), Some(e) if e == expected || e == legacy) {
anyhow::bail!(
"forge account `{agent}` exists but is not this agent's (email {:?}, expected \
`{expected}`); refusing to lock it down",
`{expected}` or the legacy `{legacy}`); refusing to lock it down",
user.email
);
}
@ -1146,6 +1153,19 @@ fn agent_email(agent: &str) -> String {
format!("{agent}@hyperhive.local")
}
/// The pre-alignment email `hive-c0re::forge::users::ensure_user_exists`
/// gave an agent account before that crate's own `ensure_user_email`
/// existed — see that function's doc comment ("Existing agents were
/// created with `{name}@hive.local`; this corrects that"). Accepted by
/// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`]
/// because `ensure_user_email`'s alignment PATCH only runs on the
/// forge-host hive, and only after it has a core token and has ticked at
/// least once, so a real legacy agent can still carry this value when this
/// node reads it.
fn legacy_agent_email(agent: &str) -> String {
format!("{agent}@hive.local")
}
/// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent`
/// and nothing else. Same shape as `hive-c0re::forge::users`'
/// `sparse_edit_user_option`: `login_name` + `source_id = 0` (local auth,
@ -1606,4 +1626,27 @@ mod tests {
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
assert_eq!(body["max_repo_creation"], 0);
}
/// argus's round-2 🟡: `hive-c0re`'s `ensure_user_email` alignment pass
/// (the thing that rewrites a legacy `{name}@hive.local` account to
/// `{name}@hyperhive.local`) only runs on the forge-host hive, and only
/// once it has a core token and has ticked — so a real, pre-existing
/// agent can still be sitting on the pre-alignment `@hive.local` email
/// when this node reads it. The guard must accept that value too, not
/// just the aligned one, or a real agent would fail with "not this
/// agent's" during the rollout window.
#[tokio::test]
async fn a_pre_alignment_legacy_email_still_gets_locked_down() {
let (client, seen) = stub_forge(
(409, r#"{"message":"user already exists"}"#),
(200, r#"{"email":"alice@hive.local"}"#),
(200, "{}"),
)
.await;
client.ensure_agent_user("alice").await.unwrap();
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
assert_eq!(body["max_repo_creation"], 0);
}
}