diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index f1b4c7a3..bb4af221 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -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); + } }