agent capabilities live in a swarm KV record, set from swarmctl
An agent's capabilities move from the hive's meta repo
(`meta/capabilities.json`, injected as `HIVE_CAPABILITIES`) into the
`capabilities` field of its key in a new `agent-permissions` KV bucket,
which swarm-controller alone writes.
- swarm-queue-client: `agent_permissions` (bucket, key, `AgentPermissions`,
direct-get read, the import subject and its wire types).
- swarm-controller: creates the bucket at start; `GET|PUT|DELETE
/api/agents/{name}/permissions` (deny_unknown_fields and unknown
capability ⇒ 422, duplicate ⇒ 400, agent must be declared by a hive);
answers each hive's one-shot import, writing a key only where there is
none and only for agents that hive's wanted state declares.
- swarmctl: `agent permissions get|set|clear`.
- swarm-nats-auth: a hive reads every key of the bucket and sends its
import on its own subject; the controller subscribes to the imports and
may answer them (`responses`).
- hive-c0re: reads each of its agents' keys every 30s into
/var/lib/hyperhive/swarm-agent-permissions.json and restarts an agent,
gracefully, when its capabilities change. Unset is the compiled-in
default (ruth: manage_root_agent; everyone else: none). A failed read
keeps the stored value. On first boot it seeds that file from
meta/capabilities.json, sends the grants to the controller, and deletes
the file once the controller answers.
Deleted: the capabilities.json reader/writer, `HIVE_CAPABILITIES` in the
rendered meta flake, ruth's capability seed, the capabilities half of the
permissions rebuild job (`PermPayload::Capabilities`/`Combined`), the
dashboard's capabilities table and `capabilities_changed` event, and the
now-unused `Capability::description`.
Part of #5170.
This commit is contained in:
parent
fcda1f4a04
commit
f862354e43
47 changed files with 1943 additions and 1146 deletions
|
|
@ -66,6 +66,9 @@ 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
|
||||
# enum the hive enforces.
|
||||
hive-sh4re.workspace = true
|
||||
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`
|
||||
# everywhere downstream — see `create_agent`'s doc comment for why this is
|
||||
# defense-in-depth, not the only gate (per an argus review finding).
|
||||
|
|
@ -102,6 +105,7 @@ swarm-authelia-bridge-sock.workspace = true
|
|||
# carries.
|
||||
swarm-queue-client = { workspace = true, features = [
|
||||
"kv",
|
||||
"agent-permissions",
|
||||
"subagent-term",
|
||||
"agent-term",
|
||||
"schema",
|
||||
|
|
|
|||
503
swarm-controller/src/agent_permissions.rs
Normal file
503
swarm-controller/src/agent_permissions.rs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
//! `/api/agents/{name}/permissions` — the capabilities 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.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use futures_util::StreamExt as _;
|
||||
use hive_sh4re::permissions::Capability;
|
||||
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::wanted::WantedWriter;
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
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.
|
||||
#[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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<Vec<String>>, example = json!(["manage_root_agent"]))]
|
||||
capabilities: Option<Vec<Capability>>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AgentPermissions {
|
||||
capabilities: self.capabilities,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentPermissions> for Permissions {
|
||||
fn from(v: AgentPermissions) -> Self {
|
||||
Self {
|
||||
capabilities: v.capabilities,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
|
||||
hive_types::Ident::parse(name)
|
||||
.map(hive_types::Ident::into_string)
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
|
||||
}
|
||||
|
||||
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agent permissions: queue failure");
|
||||
error_problem(wanted_error_status(e), &format!("{e:#}"))
|
||||
}
|
||||
|
||||
async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
) -> Result<async_nats::jetstream::kv::Store, ProblemDetails> {
|
||||
agent_permissions::open_or_create(client)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&anyhow::Error::new(e)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/{name}/permissions",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "the permissions the swarm sets for the agent; `{}` \
|
||||
when it sets none", body = Permissions),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(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 read (problem+json)",
|
||||
body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn get_agent_permissions(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Permissions>, ProblemDetails> {
|
||||
let agent = agent_name(&name).map_err(refused)?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
let store = open_or_create(&client).await?;
|
||||
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
Ok(Json(current.into()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/agents/{name}/permissions",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
request_body = 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"),
|
||||
(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)",
|
||||
body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn put_agent_permissions(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<Permissions>,
|
||||
) -> Result<Json<Permissions>, ProblemDetails> {
|
||||
let agent = agent_name(&name).map_err(refused)?;
|
||||
let value = req
|
||||
.into_stored()
|
||||
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
if !live(&state).await?.contains(&agent) {
|
||||
return Err(error_problem(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
||||
));
|
||||
}
|
||||
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)
|
||||
|| write(&store, &agent, &value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, ?value, "set agent permissions");
|
||||
return Ok(Json(value.into()));
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/agents/{name}/permissions",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 204, description = "cleared; the hive's default applies again"),
|
||||
(status = 400, description = "not an identifier (problem+json)", body = String),
|
||||
(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 cleared (problem+json)",
|
||||
body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn delete_agent_permissions(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<StatusCode, ProblemDetails> {
|
||||
let agent = agent_name(&name).map_err(refused)?;
|
||||
let client = queue(&state).map_err(refused)?;
|
||||
let store = open_or_create(&client).await?;
|
||||
store
|
||||
.delete(&agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
|
||||
tracing::info!(%agent, "cleared agent permissions");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives: Vec<String>) {
|
||||
tokio::spawn(async move {
|
||||
const RETRY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
let store = loop {
|
||||
if swarm_queue_client::ensure_connected(&client).is_ok() {
|
||||
match agent_permissions::open_or_create(&client).await {
|
||||
Ok(store) => break store,
|
||||
Err(e) => tracing::warn!(
|
||||
error = swarm_queue_client::chain(&e),
|
||||
"creating the agent-permissions bucket failed; retrying"
|
||||
),
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(RETRY).await;
|
||||
};
|
||||
let subject = agent_permissions::import_subject_wildcard();
|
||||
loop {
|
||||
match client.subscribe(subject.clone()).await {
|
||||
Ok(mut requests) => {
|
||||
tracing::info!(%subject, "agent permissions: answering imports");
|
||||
while let Some(msg) = requests.next().await {
|
||||
let Some(reply) = msg.reply.clone() else {
|
||||
continue;
|
||||
};
|
||||
let answer = answer_import(&store, &wanted, &hives, &msg).await;
|
||||
let encoded = serde_json::to_vec(&answer).unwrap_or_default();
|
||||
if let Err(e) = client.publish(reply, encoded.into()).await {
|
||||
tracing::warn!(error = %e, "agent permissions: answering an import failed");
|
||||
}
|
||||
}
|
||||
tracing::warn!(%subject, "agent permissions: import subscription ended");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%subject, error = %e, "agent permissions: subscribe failed");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn answer_import(
|
||||
store: &async_nats::jetstream::kv::Store,
|
||||
wanted: &WantedWriter,
|
||||
hives: &[String],
|
||||
msg: &async_nats::Message,
|
||||
) -> ImportReply {
|
||||
let failed = |error: String| {
|
||||
tracing::warn!(subject = %msg.subject, %error, "agent permissions: import refused");
|
||||
ImportReply::Failed { error }
|
||||
};
|
||||
let Some(hive) = agent_permissions::hive_from_import_subject(&msg.subject) else {
|
||||
return failed(format!("{} is not an import subject", msg.subject));
|
||||
};
|
||||
if !hives.iter().any(|h| h == hive) {
|
||||
return failed(format!("{hive} is not a hive this controller serves"));
|
||||
}
|
||||
let import: Import = match serde_json::from_slice(&msg.payload) {
|
||||
Ok(import) => import,
|
||||
Err(e) => return failed(format!("the import does not decode: {e}")),
|
||||
};
|
||||
let declared = match wanted.view(hive).await {
|
||||
Ok(d) => crate::agent_renewal::live_agents(d.as_slice()),
|
||||
Err(e) => return failed(format!("reading {hive}'s wanted state: {e:#}")),
|
||||
};
|
||||
match import_into(store, &declared, import).await {
|
||||
Ok(reply) => {
|
||||
tracing::info!(%hive, ?reply, "agent permissions: imported a hive's grants");
|
||||
reply
|
||||
}
|
||||
Err(e) => failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write each agent's imported capabilities under its key when it has none,
|
||||
/// for the agents in `declared` only.
|
||||
async fn import_into(
|
||||
store: &async_nats::jetstream::kv::Store,
|
||||
declared: &BTreeSet<String>,
|
||||
import: Import,
|
||||
) -> anyhow::Result<ImportReply> {
|
||||
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (agent, caps) in import.capabilities {
|
||||
if !declared.contains(&agent) {
|
||||
skipped.push(agent);
|
||||
continue;
|
||||
}
|
||||
let value = AgentPermissions {
|
||||
capabilities: Some(caps),
|
||||
};
|
||||
if write(store, &agent, &value, None).await? {
|
||||
written.push(agent);
|
||||
} else {
|
||||
kept.push(agent);
|
||||
}
|
||||
}
|
||||
Ok(ImportReply::Imported {
|
||||
written,
|
||||
kept,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Permissions, delete_agent_permissions, get_agent_permissions, import_into,
|
||||
put_agent_permissions,
|
||||
};
|
||||
use crate::test_nats::TestNats;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use hive_sh4re::permissions::Capability;
|
||||
use swarm_queue_client::agent_permissions::{Import, ImportReply};
|
||||
|
||||
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
|
||||
let store = swarm_queue_client::agent_permissions::open_or_create(client)
|
||||
.await
|
||||
.expect("bucket");
|
||||
store
|
||||
.get(agent)
|
||||
.await
|
||||
.expect("read")
|
||||
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
|
||||
}
|
||||
|
||||
fn granted() -> Permissions {
|
||||
Permissions {
|
||||
capabilities: Some(vec![Capability::ManageRootAgent]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(
|
||||
state: &crate::AppState,
|
||||
agent: &str,
|
||||
value: Permissions,
|
||||
) -> Result<axum::Json<Permissions>, problem_details::ProblemDetails> {
|
||||
put_agent_permissions(
|
||||
State(state.clone()),
|
||||
Path(agent.to_owned()),
|
||||
axum::Json(value),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get(state: &crate::AppState, agent: &str) -> Permissions {
|
||||
get_agent_permissions(State(state.clone()), Path(agent.to_owned()))
|
||||
.await
|
||||
.expect("read")
|
||||
.0
|
||||
}
|
||||
|
||||
/// Through the real handlers: a grant lands as the hive reads it, an empty
|
||||
/// list is stored as an empty list, and `DELETE` takes the key out.
|
||||
#[tokio::test]
|
||||
async fn the_route_writes_what_a_hive_reads_and_delete_clears_it() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
|
||||
assert_eq!(
|
||||
put(&state, "atlas", granted()).await.expect("set").0,
|
||||
granted()
|
||||
);
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":["manage_root_agent"]}"#)
|
||||
);
|
||||
assert_eq!(get(&state, "atlas").await, granted());
|
||||
|
||||
let none = Permissions {
|
||||
capabilities: Some(Vec::new()),
|
||||
};
|
||||
let _ = put(&state, "atlas", none.clone()).await.expect("set");
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":[]}"#)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
);
|
||||
assert_eq!(published(&client, "atlas").await, None);
|
||||
assert_eq!(get(&state, "atlas").await, Permissions::default());
|
||||
}
|
||||
|
||||
/// A name no hive declares, or a name that is not an identifier, is a 400
|
||||
/// and writes nothing.
|
||||
#[tokio::test]
|
||||
async fn an_undeclared_or_malformed_agent_is_a_400() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
|
||||
for agent in ["iris", "Not_An_Ident"] {
|
||||
let problem = put(&state, agent, granted()).await.expect_err("refused");
|
||||
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{agent}");
|
||||
}
|
||||
assert_eq!(published(&client, "iris").await, None);
|
||||
}
|
||||
|
||||
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
|
||||
use tower::ServiceExt as _;
|
||||
app.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::put("/api/agents/atlas/permissions")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("router answers")
|
||||
.status()
|
||||
}
|
||||
|
||||
/// Behind the real router: an unknown field or capability 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"]}"#;
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let _ = put(&state, "atlas", granted()).await.expect("set");
|
||||
let app = crate::build_app(state);
|
||||
|
||||
for (body, status) in [
|
||||
(
|
||||
r#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(r#"{"capabilites":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
r#"{"capabilities":["fly_to_the_moon"]}"#,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
r#"{"capabilities":["manage_root_agent","manage_root_agent"]}"#,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
] {
|
||||
assert_eq!(put_raw(&app, body).await, status, "{body}");
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(GRANTED),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
// Control: the same router and request do reach the bucket.
|
||||
assert_eq!(put_raw(&app, "{}").await, StatusCode::OK);
|
||||
assert_eq!(published(&client, "atlas").await.as_deref(), Some("{}"));
|
||||
}
|
||||
|
||||
/// The import writes a key only where there is none, and only for the
|
||||
/// agents the sending hive declares.
|
||||
#[tokio::test]
|
||||
async fn an_import_writes_only_missing_keys_of_the_hives_own_agents() {
|
||||
let nats = TestNats::start();
|
||||
let client = nats.client().await;
|
||||
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
|
||||
.await
|
||||
.expect("bucket");
|
||||
store
|
||||
.put("atlas", br#"{"capabilities":[]}"#.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]),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
|
||||
let reply = import_into(&store, &declared, import)
|
||||
.await
|
||||
.expect("imported");
|
||||
assert_eq!(
|
||||
reply,
|
||||
ImportReply::Imported {
|
||||
written: vec!["ruth".to_owned()],
|
||||
kept: vec!["atlas".to_owned()],
|
||||
skipped: vec!["iris".to_owned()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
published(&client, "atlas").await.as_deref(),
|
||||
Some(r#"{"capabilities":[]}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
published(&client, "ruth").await.as_deref(),
|
||||
Some(r#"{"capabilities":["manage_root_agent"]}"#)
|
||||
);
|
||||
assert_eq!(published(&client, "iris").await, None);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ mod agent_identity;
|
|||
mod agent_input;
|
||||
mod agent_limits;
|
||||
mod agent_login;
|
||||
mod agent_permissions;
|
||||
mod agent_renewal;
|
||||
mod agent_state_stream;
|
||||
mod agent_status;
|
||||
|
|
@ -2877,11 +2878,22 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
|
|||
}
|
||||
|
||||
/// The per-agent background passes: subagent and terminal streams, and
|
||||
/// schedule keys.
|
||||
/// schedule keys, and the permissions bucket and its import.
|
||||
/// Lifted out of `main` for `clippy::too_many_lines`.
|
||||
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
spawn_subagent_streams(status, hives);
|
||||
spawn_schedule_reconcile(status, hives);
|
||||
spawn_agent_permissions(status, hives);
|
||||
}
|
||||
|
||||
/// Create the agent-permissions bucket and answer hives' imports when a swarm
|
||||
/// queue is wired up. Same shape as [`spawn_subagent_streams`].
|
||||
fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
agent_permissions::spawn(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// Start rewriting every agent's schedule key from the definitions when a
|
||||
|
|
@ -3493,6 +3505,11 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(get_config_prs))
|
||||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_forge_token))
|
||||
|
|
|
|||
Loading…
Reference in a new issue