Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: refuse linking over an existing account

The matrix, forge and github link routes wrote their credential
unconditionally, so linking a name that was already linked replaced the
working account. For matrix that lost the device the agent's crypto store
belongs to (#4838).

Each route now reads the account's store path first and answers 409,
naming the existing account, when something is stored there. Nothing is
written. Replacing an account takes the delete from #4899, then a link.

The matrix route checks before password mode's login, so a refused link
mints no new device at the homeserver.

The check is a read then a write, not an atomic step; two concurrent
links to one name can still both pass it.

Closes #4856
This commit is contained in:
atlas 2026-10-03 13:29:26 +02:00
commit 8ad2af735e
10 changed files with 303 additions and 63 deletions

View file

@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::linked_accounts::link;
use super::{AppState, error_problem, swarm_hive};
/// The account to store for one agent's external forge.
@ -34,10 +35,8 @@ pub struct PutForgeAccountResponse {
url: String,
}
/// Store an agent's external forge account.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// account the agent will next read rather than adding a second one.
/// Store an agent's external forge account, unless one is stored under the
/// label already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
@ -50,7 +49,8 @@ pub struct PutForgeAccountResponse {
responses(
(status = 200, description = "stored", body = PutForgeAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
@ -76,16 +76,21 @@ pub async fn put_forge_account(
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
store.write(&secret_path, &account).await.map_err(|e| {
link(&store, &secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the forge account failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
e.problem(&existing(&agent, &label))
})?;
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
Ok(Json(PutForgeAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has forge account {label:?}")
}
/// The request as it is stored, or why it cannot be.
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
let url = req.url.trim().trim_end_matches('/');
@ -197,4 +202,38 @@ mod tests {
"{problem:?}"
);
}
#[tokio::test]
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, link, tests::FakeStore};
use swarm_secret_client::forge;
let store = FakeStore::default();
let path = forge::account_path("atlas", "codeberg").expect("a valid path");
let first = account(request("https://codeberg.org", "t0k3n-first")).expect("valid");
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("an account is stored")
.problem(&super::existing("atlas", "codeberg"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has forge account "codeberg""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: forge::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://codeberg.org");
assert_eq!(kept.value, "t0k3n-first");
}
}