Watch
0
0
Fork
You've already forked hyperhive
0

let the operator set, replace and remove an agent's backend env from the swarm ui

This commit is contained in:
atlas 2026-10-28 01:33:52 +01:00 • committed by mara
commit 57b04d6c49

View file

@ -2,13 +2,15 @@
//! UI's agent panel lists, and deletes.
//!
//! [`link`] is the write the three link routes share: it refuses a path that
//! already holds an account, so replacing one takes a delete first.
//! already holds an account, so replacing one takes a delete first. The
//! backend environment route is the exception: it overwrites.
//!
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
//! which `matrix_account::agent_token` mints into the same directory. Each
//! entry is read for its host and nothing else leaves this module:
//! [`LinkedAccount`] has no field a credential could land in.
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
//! [`crate::github_account`] and [`crate::backend_env`] write, plus the
//! agent's own `main` matrix account, which `matrix_account::agent_token`
//! mints into the same directory. Each entry is read for its host and nothing
//! else leaves this module: [`LinkedAccount`] has no field a credential could
//! land in. A backend environment is listed as set, with no host.
//!
//! Naming the matrix accounts and forge labels takes `list` on each agent's
//! `matrix` and `forge` metadata directories, and deleting an account takes
@ -18,6 +20,9 @@
//! A delete removes the store entry only, and the agent is not told. Its
//! GitHub and forge token files stay in place, since the units that write them
//! never delete one; its matrix daemon drops the account when it next re-lists.
//! A deleted backend environment leaves the agent's copy in `/run` until its
//! next boot, and an agent with a non-empty `backendEnvironmentFile` creates
//! the leaf again from that file.
use std::future::Future;
@ -26,7 +31,7 @@ use axum::extract::State;
use axum::http::StatusCode;
use serde::de::{DeserializeOwned, IgnoredAny};
use serde::{Deserialize, Serialize};
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
use utoipa::{IntoParams, ToSchema};
use crate::routes::agents::swarm_hive;
@ -43,13 +48,14 @@ pub enum AccountKind {
Matrix,
Forgejo,
Github,
Backend,
}
/// One account linked to an agent. Never carries the credential.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
pub struct LinkedAccount {
kind: AccountKind,
/// The matrix account name, the forge label, or `github`.
/// The matrix account name, the forge label, `github`, or `backend-env`.
#[schema(example = "main")]
name: String,
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
@ -185,7 +191,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
}
/// Every account the store holds for `agent`: matrix, then forgejo, then
/// github.
/// github, then the backend environment.
///
/// A missing directory lists nothing, and a listed name whose object is gone
/// gets no entry.
@ -238,6 +244,16 @@ pub(crate) async fn linked_accounts(
});
}
let path = backend_env::env_path(agent)?;
if store.read_optional::<IgnoredAny>(&path).await?.is_some() {
out.push(LinkedAccount {
kind: AccountKind::Backend,
name: "backend-env".to_owned(),
host: None,
reserved: false,
});
}
Ok(out)
}
@ -567,13 +583,55 @@ pub async fn delete_github_account(
Ok(StatusCode::NO_CONTENT)
}
/// Delete an agent's backend environment from the store, every version of it.
#[utoipa::path(
delete,
path = "/api/hives/{hive}/agents/{agent}/backend-env",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the environment belongs to"),
),
responses(
(status = 204, description = "deleted"),
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
(status = 404, description = "no backend environment is stored (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn delete_backend_env(
State(state): State<AppState>,
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let path = backend_env::env_path(&agent)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let store = state.accounts.open().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
remove_plain::<IgnoredAny>(&store, &path)
.await
.map_err(|e| {
tracing::warn!(%hive, %agent, error = ?e, "deleting the backend env failed");
e.problem()
})?;
tracing::info!(%hive, %agent, "backend env deleted");
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::de::{DeserializeOwned, IgnoredAny};
use serde_json::{Value, json};
use swarm_secret_client::Error;
@ -596,7 +654,15 @@ pub(crate) mod tests {
}
impl FakeStore {
fn with(self, path: &str, object: Value) -> Self {
/// A store whose every call fails.
pub(crate) fn denying() -> Self {
Self {
denied: true,
..Self::default()
}
}
pub(crate) fn with(self, path: &str, object: Value) -> Self {
self.objects
.lock()
.expect("not poisoned")
@ -705,6 +771,10 @@ pub(crate) mod tests {
"swarm/agents/atlas/github-token",
json!({"value": "t0k3n-gh"}),
)
.with(
"swarm/agents/atlas/backend-env",
json!({"env": "ANTHROPIC_API_KEY=t0k3n-env\n"}),
)
// Another agent's accounts, and the agent's own forge token beside
// its `forge/` directory: neither is a linked account of atlas.
.with(
@ -745,6 +815,7 @@ pub(crate) mod tests {
false
),
row(AccountKind::Github, "github", Some("github.com"), false),
row(AccountKind::Backend, "backend-env", None, false),
]
);
}
@ -813,12 +884,16 @@ pub(crate) mod tests {
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
.await
.expect("stored");
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env")
.await
.expect("stored");
assert_eq!(
deleted(&store),
[
"swarm/agents/atlas/matrix/catgirl",
"swarm/agents/atlas/forge/myforge",
"swarm/agents/atlas/github-token",
"swarm/agents/atlas/backend-env",
]
);
}
@ -835,6 +910,7 @@ pub(crate) mod tests {
.await,
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
];
for result in results {
let removal = result.expect_err("nothing is stored");
@ -1023,4 +1099,31 @@ pub(crate) mod tests {
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]);
}
async fn delete_backend(
store: &Arc<FakeStore>,
agent: &str,
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
super::delete_backend_env(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
)
.await
}
#[tokio::test]
async fn deleting_a_backend_env_removes_that_agents_leaf_only() {
let store = Arc::new(every_kind());
let status = delete_backend(&store, "atlas").await.expect("stored");
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
assert_eq!(deleted(&store), ["swarm/agents/atlas/backend-env"]);
let problem = delete_backend(&store, "red").await.expect_err("none");
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
for bad in ["../x", "Atlas"] {
let problem = delete_backend(&store, bad).await.expect_err("refused");
assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_REQUEST));
}
assert_eq!(deleted(&store).len(), 1);
}
}