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
|
|
@ -229,7 +229,9 @@ impl Policy {
|
|||
return Some(Permissions {
|
||||
publish: self.reader_subjects(),
|
||||
subscribe: Some(Self::reader_subscribe(client_id)),
|
||||
responses: None,
|
||||
// The answer to a hive's permissions import. NATS lets it
|
||||
// answer only a message it received, once.
|
||||
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
|
||||
});
|
||||
}
|
||||
if self.review_classifiers.iter().any(|c| c == client_id) {
|
||||
|
|
@ -307,7 +309,7 @@ impl Policy {
|
|||
|
||||
/// What a reader may subscribe to: every agent's terminal, its
|
||||
/// subagents' terminals and its turn state, which the controller relays
|
||||
/// to swarm-ui, and its own reply inbox.
|
||||
/// to swarm-ui, every hive's permissions import, and its own reply inbox.
|
||||
///
|
||||
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
|
||||
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
|
||||
|
|
@ -318,6 +320,7 @@ impl Policy {
|
|||
swarm_queue_client::term_subject_wildcard(),
|
||||
swarm_queue_client::subagent_term::subject_wildcard(),
|
||||
swarm_queue_client::agent_state_subject_wildcard(),
|
||||
swarm_queue_client::agent_permissions::import_subject_wildcard(),
|
||||
Self::inbox(client_id),
|
||||
]
|
||||
}
|
||||
|
|
@ -561,6 +564,17 @@ impl Policy {
|
|||
"$JS.API.DIRECT.GET.{}.>",
|
||||
swarm_queue_client::agent_limits::STREAM
|
||||
));
|
||||
// The capabilities the swarm grants those agents
|
||||
// (`swarm_queue_client::agent_permissions`), read the same way and
|
||||
// every key for the same reason.
|
||||
subjects.push(format!(
|
||||
"$JS.API.DIRECT.GET.{}.>",
|
||||
swarm_queue_client::agent_permissions::STREAM
|
||||
));
|
||||
// The one-shot import of the grants this hive's meta repo held, sent
|
||||
// as a request on this hive's own subject: the controller takes the
|
||||
// hive from the subject, so one hive cannot import for another.
|
||||
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
|
||||
subjects.extend(
|
||||
self.extra_hive_subjects
|
||||
.iter()
|
||||
|
|
@ -837,6 +851,78 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A hive reads any agent's permissions and can change none of them; an
|
||||
/// agent, even one granted direct gets on its own keys elsewhere, reads
|
||||
/// none of them.
|
||||
#[test]
|
||||
fn a_hive_may_read_every_agents_permissions_and_an_agent_none() {
|
||||
use swarm_queue_client::agent_permissions::{STREAM, direct_get_subject, key_subject};
|
||||
|
||||
let p = policy()
|
||||
.with_agent_token_subjects(vec![
|
||||
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.{agent}".to_owned(),
|
||||
])
|
||||
.expect("a per-agent template is valid");
|
||||
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
|
||||
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
|
||||
for agent in ["atlas", "ruth"] {
|
||||
assert!(hive_may(&direct_get_subject(agent)), "cannot read {agent}");
|
||||
}
|
||||
for forbidden in [
|
||||
key_subject("atlas"),
|
||||
format!("$JS.API.STREAM.CREATE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.DELETE.{STREAM}"),
|
||||
format!("$JS.API.STREAM.PURGE.{STREAM}"),
|
||||
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
|
||||
] {
|
||||
assert!(!hive_may(&forbidden), "hive alpha may publish {forbidden}");
|
||||
}
|
||||
|
||||
let agent = p.agent_token_permissions("atlas").expect("configured");
|
||||
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
|
||||
assert!(
|
||||
!agent_may(&direct_get_subject("atlas")),
|
||||
"an agent may read its own permissions: {:?}",
|
||||
agent.publish
|
||||
);
|
||||
// Control: the agent's direct-get grant is real, just not this bucket.
|
||||
assert!(agent_may(
|
||||
&swarm_queue_client::agent_inference::direct_get_subject("atlas")
|
||||
));
|
||||
}
|
||||
|
||||
/// A hive sends its import on its own subject only; the reader hears every
|
||||
/// hive's and may answer, and no other role may send one.
|
||||
#[test]
|
||||
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
|
||||
use swarm_queue_client::agent_permissions::{
|
||||
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
|
||||
};
|
||||
|
||||
let p = policy();
|
||||
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
|
||||
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
|
||||
assert!(hive_may(&import_subject("alpha")));
|
||||
assert!(!hive_may(&import_subject("beta")));
|
||||
|
||||
let reader = p.permissions("swarm-controller").expect("the reader");
|
||||
assert!(
|
||||
reader
|
||||
.subscribe
|
||||
.as_ref()
|
||||
.expect("scoped")
|
||||
.contains(&import_subject_wildcard())
|
||||
);
|
||||
assert_eq!(reader.responses, Some(IMPORT_REPLY_DEADLINE));
|
||||
assert!(
|
||||
!reader
|
||||
.publish
|
||||
.iter()
|
||||
.any(|g| covers(g, &import_subject("alpha"))),
|
||||
"the reader may send an import as a hive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extra_subject_without_the_placeholder_is_refused() {
|
||||
// 🩸 The option exists to put a second stream inside ONE hive's
|
||||
|
|
@ -1356,7 +1442,6 @@ mod tests {
|
|||
.expect("a per-agent template is valid");
|
||||
let reader = p.permissions("swarm-controller").expect("the reader");
|
||||
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
|
||||
assert_eq!(reader.responses, None);
|
||||
for (who, grant) in [
|
||||
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
||||
(
|
||||
|
|
|
|||
Loading…
Reference in a new issue