agent permissions: one set, tool groups included, set at agent creation
mara on #5170: "dont split caps and perms. its all perms. / dont include the now empty tool groups / nothing set -> default / also add perms to agent creation screen". - hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution, web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`; `lifecycle`/`forge` are gone. `Permission::default_for` is the default for an agent the swarm sets nothing for. - The record is `{ "permissions": [...] }`. Missing key, unset field or no successful read yet = default; `[]` = none; a failed read after a good one keeps the last value. - hive-agent reads its own key every 10s and builds each turn's --tools, --allowedTools and MCP config (rewritten before every turn) from it; the subagent daemon reads it on each start/continue. Agents get a read-only grant on their own key. - hive-c0re restarts an agent only when its manage_root_agent changes. The import now merges meta/capabilities.json and meta/tool-groups.json into one list per agent and deletes both; the "import pending, don't read" decision is `ImportOutcome::read_allowed`, table-tested. - swarm-controller: `GET /api/permissions` (names + default) and an optional `permissions` on `POST /api/agents`, stored before the job is queued. swarm-ui's create-agent form gets a permissions field. - Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon, tool_groups.rs, ruth's tool-group seed, the permissions rebuild job (PermPayload/WritePermFile/perm_change), the dashboard permissions tab and its stale-entries panel. Part of #5170.
This commit is contained in:
parent
f862354e43
commit
f0844f6da3
73 changed files with 1578 additions and 2742 deletions
|
|
@ -66,7 +66,7 @@ sha2.workspace = true
|
|||
# `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width
|
||||
# the forge's own `.diff` does not offer.
|
||||
similar.workspace = true
|
||||
# The `Capability` names `/api/agents/{name}/permissions` accepts, the same
|
||||
# The `Permission` names `/api/agents/{name}/permissions` accepts, the same
|
||||
# enum the hive enforces.
|
||||
hive-sh4re.workspace = true
|
||||
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
//! `/api/agents/{name}/permissions` — the capabilities the swarm grants one
|
||||
//! `/api/agents/{name}/permissions` — the permission set the swarm grants one
|
||||
//! agent — and the one-shot import of the grants a hive's meta repo held.
|
||||
//!
|
||||
//! Each agent's permissions are one value under its own key of
|
||||
//! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this
|
||||
//! daemon alone writes. The hive running the agent reads the key and restarts
|
||||
//! the agent when its capabilities change. A field left unset leaves the agent
|
||||
//! on the hive's compiled-in default; an empty list grants nothing.
|
||||
//! daemon alone writes. The agent's harness and subagent daemon apply the tool
|
||||
//! permissions at their next turn or start; the hive running the agent
|
||||
//! restarts it when `manage_root_agent` changes. An unset field leaves the
|
||||
//! agent on the compiled-in default; an empty list grants nothing.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -14,13 +15,13 @@ use axum::Json;
|
|||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use futures_util::StreamExt as _;
|
||||
use hive_sh4re::permissions::Capability;
|
||||
use hive_sh4re::permissions::Permission;
|
||||
use problem_details::ProblemDetails;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write};
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write, write_revision};
|
||||
use crate::wanted::WantedWriter;
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
|
|
@ -29,39 +30,45 @@ use crate::{AppState, error_problem, wanted_error_status};
|
|||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
/// One agent's permissions. A field left out is not set by the swarm, and the
|
||||
/// hive's default applies; an empty list grants nothing.
|
||||
/// compiled-in default applies; an empty list grants nothing.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Permissions {
|
||||
/// Capability names, `snake_case`: `manage_root_agent` mounts every agent's
|
||||
/// state and config into this one for recovery.
|
||||
/// Permission names, `snake_case`: `messaging`, `meta`, `inbox`,
|
||||
/// `execution`, `web_tools`, `manage_root_agent`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<Vec<String>>, example = json!(["manage_root_agent"]))]
|
||||
capabilities: Option<Vec<Capability>>,
|
||||
#[schema(value_type = Option<Vec<String>>, example = json!(["messaging", "meta", "inbox", "execution"]))]
|
||||
permissions: Option<Vec<Permission>>,
|
||||
}
|
||||
|
||||
impl Permissions {
|
||||
/// The value to store, or why it is refused.
|
||||
fn into_stored(self) -> Result<AgentPermissions, String> {
|
||||
if let Some(caps) = &self.capabilities {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for cap in caps {
|
||||
if !seen.insert(*cap) {
|
||||
let name: &str = (*cap).into();
|
||||
return Err(format!("capability {name:?} is listed twice"));
|
||||
}
|
||||
}
|
||||
if let Some(perms) = &self.permissions {
|
||||
unique(perms)?;
|
||||
}
|
||||
Ok(AgentPermissions {
|
||||
capabilities: self.capabilities,
|
||||
permissions: self.permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a list that names a permission twice.
|
||||
pub(crate) fn unique(perms: &[Permission]) -> Result<(), String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for perm in perms {
|
||||
if !seen.insert(*perm) {
|
||||
let name: &str = (*perm).into();
|
||||
return Err(format!("permission {name:?} is listed twice"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<AgentPermissions> for Permissions {
|
||||
fn from(v: AgentPermissions) -> Self {
|
||||
Self {
|
||||
capabilities: v.capabilities,
|
||||
permissions: v.permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,8 +123,8 @@ pub(crate) async fn get_agent_permissions(
|
|||
/// Set the permissions the swarm grants one agent.
|
||||
///
|
||||
/// The value replaces the stored one whole. The hive running the agent reads
|
||||
/// it within 30 seconds and, when the agent's capabilities changed, restarts
|
||||
/// it after its current turn.
|
||||
/// it within 30 seconds and restarts the agent, after its current turn, when
|
||||
/// `manage_root_agent` changed; the agent applies the rest at its next turn.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/agents/{name}/permissions",
|
||||
|
|
@ -126,8 +133,8 @@ pub(crate) async fn get_agent_permissions(
|
|||
responses(
|
||||
(status = 200, description = "the permissions as stored", body = Permissions),
|
||||
(status = 400, description = "not an identifier, not declared on any hive, or a \
|
||||
capability listed twice (problem+json)", body = String),
|
||||
(status = 422, description = "an unknown field or capability name"),
|
||||
permission listed twice (problem+json)", body = String),
|
||||
(status = 422, description = "an unknown field or permission name"),
|
||||
(status = 503, description = "no swarm queue is configured on this host, or it is \
|
||||
not connected (problem+json)", body = String),
|
||||
(status = 500, description = "the permissions could not be written (problem+json)",
|
||||
|
|
@ -199,6 +206,101 @@ pub(crate) async fn delete_agent_permissions(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Every permission name, and the set an agent the swarm sets nothing for
|
||||
/// holds — so a client lists them without a copy of its own.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub(crate) struct Catalogue {
|
||||
#[schema(value_type = Vec<String>)]
|
||||
all: Vec<Permission>,
|
||||
#[schema(value_type = Vec<String>)]
|
||||
default: Vec<Permission>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/permissions",
|
||||
responses((status = 200, description = "every permission name, and the default set",
|
||||
body = Catalogue)),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn get_permission_catalogue() -> Json<Catalogue> {
|
||||
Json(Catalogue {
|
||||
all: Permission::ALL.to_vec(),
|
||||
default: Permission::DEFAULT.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The value `POST /api/agents` stores for `perms`, or why it is refused.
|
||||
/// The agent is not declared on its hive yet, so the "declared by a hive"
|
||||
/// check of [`put_agent_permissions`] does not apply.
|
||||
pub(crate) fn at_create(perms: Vec<Permission>) -> Result<AgentPermissions, String> {
|
||||
unique(&perms)?;
|
||||
Ok(AgentPermissions {
|
||||
permissions: Some(perms),
|
||||
})
|
||||
}
|
||||
|
||||
/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
|
||||
pub(crate) type Replaced = Option<crate::schedules::Replaced<AgentPermissions>>;
|
||||
|
||||
/// Replace `agent`'s stored permissions with `value`.
|
||||
pub(crate) async fn store(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
value: &AgentPermissions,
|
||||
) -> Result<Replaced, ProblemDetails> {
|
||||
let store = open_or_create(client).await?;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (current, revision, undecodable) = read_or_default::<AgentPermissions>(&store, agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
if current == *value && !undecodable {
|
||||
tracing::info!(%agent, ?value, "set agent permissions at create");
|
||||
return Ok(None);
|
||||
}
|
||||
// A deleted key reads as present at its marker's revision, so ask
|
||||
// whether it holds a value before keeping one to put back.
|
||||
let held = store
|
||||
.get(agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?
|
||||
.is_some();
|
||||
if let Some(written) = write_revision(&store, agent, value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, ?value, "set agent permissions at create");
|
||||
return Ok(Some(crate::schedules::Replaced::new(
|
||||
held.then_some(current),
|
||||
written,
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Undo what [`store`] did, unless another write has landed since. Best
|
||||
/// effort: a failure is logged, since the caller is already on its way to an
|
||||
/// error of its own.
|
||||
pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) {
|
||||
let Some(replaced) = replaced else { return };
|
||||
let undone = match open_or_create(client).await {
|
||||
Ok(store) => replaced.undo(&store, agent).await,
|
||||
Err(e) => Err(anyhow::anyhow!("{e:?}")),
|
||||
};
|
||||
match undone {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::info!(%agent, "left the agent's permissions: another write landed since");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's permissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the bucket as soon as the queue is connected, then answer every
|
||||
/// hive's import. Creating it first means a hive that finds no bucket knows
|
||||
/// the queue is not serving it, rather than that nothing is set.
|
||||
|
|
@ -276,7 +378,7 @@ async fn answer_import(
|
|||
}
|
||||
}
|
||||
|
||||
/// Write each agent's imported capabilities under its key when it has none,
|
||||
/// Write each agent's imported permissions under its key when it has none,
|
||||
/// for the agents in `declared` only.
|
||||
async fn import_into(
|
||||
store: &async_nats::jetstream::kv::Store,
|
||||
|
|
@ -284,13 +386,13 @@ async fn import_into(
|
|||
import: Import,
|
||||
) -> anyhow::Result<ImportReply> {
|
||||
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (agent, caps) in import.capabilities {
|
||||
for (agent, caps) in import.permissions {
|
||||
if !declared.contains(&agent) {
|
||||
skipped.push(agent);
|
||||
continue;
|
||||
}
|
||||
let value = AgentPermissions {
|
||||
capabilities: Some(caps),
|
||||
permissions: Some(caps),
|
||||
};
|
||||
if write(store, &agent, &value, None).await? {
|
||||
written.push(agent);
|
||||
|
|
@ -314,7 +416,7 @@ mod tests {
|
|||
use crate::test_nats::TestNats;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use hive_sh4re::permissions::Capability;
|
||||
use hive_sh4re::permissions::Permission;
|
||||
use swarm_queue_client::agent_permissions::{Import, ImportReply};
|
||||
|
||||
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
|
||||
|
|
@ -330,7 +432,7 @@ mod tests {
|
|||
|
||||
fn granted() -> Permissions {
|
||||
Permissions {
|
||||
capabilities: Some(vec![Capability::ManageRootAgent]),
|
||||
permissions: Some(vec![Permission::ManageRootAgent]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,17 +470,17 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":["manage_root_agent"]}"#)
|
||||
Some(r#"{"permissions":["manage_root_agent"]}"#)
|
||||
);
|
||||
assert_eq!(get(&state, "atlas").await, granted());
|
||||
|
||||
let none = Permissions {
|
||||
capabilities: Some(Vec::new()),
|
||||
permissions: Some(Vec::new()),
|
||||
};
|
||||
let _ = put(&state, "atlas", none.clone()).await.expect("set");
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":[]}"#)
|
||||
Some(r#"{"permissions":[]}"#)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -418,11 +520,11 @@ mod tests {
|
|||
.status()
|
||||
}
|
||||
|
||||
/// Behind the real router: an unknown field or capability name is a 422,
|
||||
/// Behind the real router: an unknown field or permission name is a 422,
|
||||
/// a duplicate is a 400, and none of them changes what is stored.
|
||||
#[tokio::test]
|
||||
async fn a_bad_body_is_refused_and_the_grant_stays() {
|
||||
const GRANTED: &str = r#"{"capabilities":["manage_root_agent"]}"#;
|
||||
const GRANTED: &str = r#"{"permissions":["manage_root_agent"]}"#;
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
|
|
@ -431,16 +533,20 @@ mod tests {
|
|||
|
||||
for (body, status) in [
|
||||
(
|
||||
r#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
|
||||
r#"{"permissions":["manage_root_agent"],"tool_groups":["meta"]}"#,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(r#"{"capabilites":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(r#"{"permisions":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
r#"{"capabilities":["fly_to_the_moon"]}"#,
|
||||
r#"{"permissions":["lifecycle"]}"#,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
r#"{"capabilities":["manage_root_agent","manage_root_agent"]}"#,
|
||||
r#"{"permissions":["fly_to_the_moon"]}"#,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
r#"{"permissions":["manage_root_agent","manage_root_agent"]}"#,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
] {
|
||||
|
|
@ -466,15 +572,15 @@ mod tests {
|
|||
.await
|
||||
.expect("bucket");
|
||||
store
|
||||
.put("atlas", br#"{"capabilities":[]}"#.to_vec().into())
|
||||
.put("atlas", br#"{"permissions":[]}"#.to_vec().into())
|
||||
.await
|
||||
.expect("seed");
|
||||
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
|
||||
let import = Import {
|
||||
capabilities: [
|
||||
("atlas".to_owned(), vec![Capability::ManageRootAgent]),
|
||||
("ruth".to_owned(), vec![Capability::ManageRootAgent]),
|
||||
("iris".to_owned(), vec![Capability::ManageRootAgent]),
|
||||
permissions: [
|
||||
("atlas".to_owned(), vec![Permission::ManageRootAgent]),
|
||||
("ruth".to_owned(), vec![Permission::ManageRootAgent]),
|
||||
("iris".to_owned(), vec![Permission::ManageRootAgent]),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
|
|
@ -492,12 +598,76 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":[]}"#)
|
||||
Some(r#"{"permissions":[]}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
published(&client, "ruth").await.as_deref(),
|
||||
Some(r#"{"capabilities":["manage_root_agent"]}"#)
|
||||
Some(r#"{"permissions":["manage_root_agent"]}"#)
|
||||
);
|
||||
assert_eq!(published(&client, "iris").await, None);
|
||||
}
|
||||
|
||||
/// `POST /api/agents` with `permissions` stores them under the new agent's
|
||||
/// key in the same call; a list naming one twice is refused before
|
||||
/// anything is stored or queued.
|
||||
#[tokio::test]
|
||||
async fn create_writes_the_permissions_it_is_given() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let create = |name: &str, permissions| {
|
||||
crate::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(crate::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let problem = create(
|
||||
"iris",
|
||||
Some(vec![Permission::Messaging, Permission::Messaging]),
|
||||
)
|
||||
.await
|
||||
.expect_err("a duplicate is refused");
|
||||
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
|
||||
assert_eq!(published(&client, "iris").await, None);
|
||||
let queued = state
|
||||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.graph()
|
||||
.nodes()
|
||||
.count();
|
||||
assert_eq!(queued, 0, "nothing is queued for a refused create");
|
||||
|
||||
let _ = create("iris", Some(vec![Permission::Messaging]))
|
||||
.await
|
||||
.expect("created");
|
||||
assert_eq!(
|
||||
published(&client, "iris").await.as_deref(),
|
||||
Some(r#"{"permissions":["messaging"]}"#)
|
||||
);
|
||||
|
||||
// Control: no `permissions` writes no key, so the default applies.
|
||||
let _ = create("damocles", None).await.expect("created");
|
||||
assert_eq!(published(&client, "damocles").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_catalogue_lists_every_permission_and_the_default() {
|
||||
let json = serde_json::to_value(super::get_permission_catalogue().await.0).unwrap();
|
||||
assert_eq!(
|
||||
json["all"].as_array().map(Vec::len),
|
||||
Some(Permission::ALL.len())
|
||||
);
|
||||
assert_eq!(
|
||||
json["default"],
|
||||
serde_json::json!(["messaging", "meta", "inbox", "execution"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1742,6 +1742,11 @@ struct CreateAgentRequest {
|
|||
/// /api/agents/{name}/inference` takes them. Absent as `limits`.
|
||||
#[serde(default)]
|
||||
inference: Option<inference::InferenceSettings>,
|
||||
/// The agent's permissions, stored before the job is queued. Left out, the
|
||||
/// agent holds the default set.
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Option<Vec<String>>)]
|
||||
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
|
||||
}
|
||||
|
||||
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or
|
||||
|
|
@ -1795,10 +1800,10 @@ struct CreateAgentResponse {
|
|||
request_body = CreateAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "job chain queued", body = CreateAgentResponse),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, or a permission is listed twice (problem+json)", body = String),
|
||||
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
|
||||
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, or an `effort` that is not one of the five levels"),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String),
|
||||
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference` or `permissions` is set and cannot be stored (problem+json)", body = String),
|
||||
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
|
|
@ -1813,15 +1818,7 @@ async fn create_agent(
|
|||
|
||||
// Settings are checked before anything is created, so a bad value stops
|
||||
// the request rather than leaving an agent without them.
|
||||
let limits = req
|
||||
.limits
|
||||
.map(agent_limits::Caps::into_stored)
|
||||
.transpose()
|
||||
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
|
||||
let inference = req
|
||||
.inference
|
||||
.map(inference::InferenceSettings::into_stored)
|
||||
.transpose()
|
||||
let settings = CreateSettings::parse(req.limits, req.inference, req.permissions)
|
||||
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
|
||||
|
||||
// Two checks, and the second is the one that makes the field worth
|
||||
|
|
@ -1885,7 +1882,7 @@ async fn create_agent(
|
|||
}
|
||||
// Checked after the roster, so a mistyped hive stays a 400 while the queue
|
||||
// is down.
|
||||
let settings_queue = if limits.is_some() || inference.is_some() {
|
||||
let settings_queue = if settings.any() {
|
||||
Some(schedules::queue(&state).map_err(schedules::refused)?)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -1916,7 +1913,14 @@ async fn create_agent(
|
|||
// settings behind for the name.
|
||||
let stored = match &settings_queue {
|
||||
Some(client) => {
|
||||
StoredSettings::store(client, &agent, limits.as_ref(), inference.as_ref()).await?
|
||||
StoredSettings::store(
|
||||
client,
|
||||
&agent,
|
||||
settings.limits.as_ref(),
|
||||
settings.inference.as_ref(),
|
||||
settings.permissions.as_ref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => StoredSettings::default(),
|
||||
};
|
||||
|
|
@ -1948,27 +1952,67 @@ async fn create_agent(
|
|||
}))
|
||||
}
|
||||
|
||||
/// The settings a `POST /api/agents` body gives, as they are stored.
|
||||
struct CreateSettings {
|
||||
limits: Option<swarm_queue_client::agent_limits::AgentCaps>,
|
||||
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
|
||||
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
|
||||
}
|
||||
|
||||
impl CreateSettings {
|
||||
/// Each setting given, or why its `PUT` would refuse the first bad one.
|
||||
fn parse(
|
||||
limits: Option<agent_limits::Caps>,
|
||||
inference: Option<inference::InferenceSettings>,
|
||||
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
limits: limits.map(agent_limits::Caps::into_stored).transpose()?,
|
||||
inference: inference
|
||||
.map(inference::InferenceSettings::into_stored)
|
||||
.transpose()?,
|
||||
permissions: permissions.map(agent_permissions::at_create).transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any setting is given.
|
||||
fn any(&self) -> bool {
|
||||
self.limits.is_some() || self.inference.is_some() || self.permissions.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `create_agent` replaced in the settings stores, to put back if the
|
||||
/// creation is refused after the write.
|
||||
#[derive(Default)]
|
||||
struct StoredSettings {
|
||||
limits: agent_limits::Replaced,
|
||||
inference: inference::Replaced,
|
||||
permissions: agent_permissions::Replaced,
|
||||
}
|
||||
|
||||
impl StoredSettings {
|
||||
/// Store whichever of `limits` and `inference` are given. A failure on
|
||||
/// the second puts back the first.
|
||||
/// Store whichever of `limits`, `permissions` and `inference` are given, in
|
||||
/// that order. A failure on one puts back the ones stored before it.
|
||||
async fn store(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
limits: Option<&swarm_queue_client::agent_limits::AgentCaps>,
|
||||
inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
|
||||
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
|
||||
) -> Result<Self, problem_details::ProblemDetails> {
|
||||
let mut stored = Self::default();
|
||||
if let Some(limits) = limits {
|
||||
stored.limits = agent_limits::store(client, agent, limits).await?;
|
||||
}
|
||||
if let Some(permissions) = permissions {
|
||||
match agent_permissions::store(client, agent, permissions).await {
|
||||
Ok(replaced) => stored.permissions = replaced,
|
||||
Err(e) => {
|
||||
stored.roll_back(client, agent).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(inference) = inference {
|
||||
match inference::store(client, agent, inference).await {
|
||||
Ok(replaced) => stored.inference = replaced,
|
||||
|
|
@ -1984,6 +2028,7 @@ impl StoredSettings {
|
|||
async fn roll_back(self, client: &async_nats::Client, agent: &str) {
|
||||
agent_limits::restore(client, agent, self.limits).await;
|
||||
inference::restore(client, agent, self.inference).await;
|
||||
agent_permissions::restore(client, agent, self.permissions).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3510,6 +3555,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(get_config_prs))
|
||||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_forge_token))
|
||||
|
|
@ -3771,6 +3817,7 @@ mod tests {
|
|||
hive: "pr1maa".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3811,6 +3858,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3973,6 +4021,7 @@ mod tests {
|
|||
agent,
|
||||
Some(&caps(r#"{"cpu_quota":"400%"}"#)),
|
||||
Some(&inference),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(stored.is_err(), "{agent}: the inference write must fail");
|
||||
|
|
@ -3991,6 +4040,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Through the real handler: when the inference store fails after the
|
||||
/// permissions are stored, the creation is refused and the permissions go
|
||||
/// back to what they were, the earlier value or no key. The inference
|
||||
/// bucket refuses any value, as above.
|
||||
#[tokio::test]
|
||||
async fn a_failed_creation_leaves_no_permissions_behind() {
|
||||
let nats = crate::test_nats::TestNats::start();
|
||||
let client = nats.client().await;
|
||||
async_nats::jetstream::new(client.clone())
|
||||
.create_key_value(async_nats::jetstream::kv::Config {
|
||||
bucket: swarm_queue_client::agent_inference::BUCKET.to_owned(),
|
||||
history: 1,
|
||||
max_value_size: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("a bucket that refuses any value");
|
||||
let state = crate::agent_limits::tests::state(&client);
|
||||
let earlier = crate::agent_permissions::at_create(vec![
|
||||
hive_sh4re::permissions::Permission::Messaging,
|
||||
])
|
||||
.expect("valid");
|
||||
crate::agent_permissions::store(&client, "atlas", &earlier)
|
||||
.await
|
||||
.expect("an earlier grant");
|
||||
|
||||
for agent in ["atlas", "iris"] {
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(&format!(
|
||||
r#"{{"name":"{agent}","hive":"pr1ma","permissions":["web_tools"],
|
||||
"inference":{{"model":"opus"}}}}"#
|
||||
))
|
||||
.expect("body parses");
|
||||
super::create_agent(axum::extract::State(state.clone()), axum::Json(req))
|
||||
.await
|
||||
.expect_err("the inference write must fail");
|
||||
}
|
||||
let bucket = swarm_queue_client::agent_permissions::open_or_create(&client)
|
||||
.await
|
||||
.expect("bucket");
|
||||
let held =
|
||||
|value: Option<bytes::Bytes>| value.map(|v| String::from_utf8_lossy(&v).into_owned());
|
||||
assert_eq!(
|
||||
held(bucket.get("atlas").await.expect("read")).as_deref(),
|
||||
Some(r#"{"permissions":["messaging"]}"#),
|
||||
"the earlier grant comes back"
|
||||
);
|
||||
assert_eq!(
|
||||
held(bucket.get("iris").await.expect("read")),
|
||||
None,
|
||||
"a grant stored for a name with none is removed"
|
||||
);
|
||||
assert_eq!(node_count(&state.jobq), 0, "nothing may be queued");
|
||||
}
|
||||
|
||||
/// A valid cap beside an invalid model stores neither and queues nothing,
|
||||
/// on a server that could have stored the cap.
|
||||
#[tokio::test]
|
||||
|
|
@ -4044,6 +4147,7 @@ mod tests {
|
|||
hive: hive.to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5166,6 +5270,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5219,6 +5324,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5268,6 +5374,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5325,6 +5432,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5529,6 +5637,7 @@ mod tests {
|
|||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
permissions: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
Loading…
Reference in a new issue