Watch
0
0
Fork
You've already forked hyperhive
0

agent_permissions, agent_term and subagent_term take their wire contracts from swarm-wire

This commit is contained in:
atlas 2026-10-18 23:37:05 +02:00 • committed by mara
commit 839efd1642
27 changed files with 661 additions and 607 deletions

View file

@ -0,0 +1,143 @@
//! The per-agent permissions KV bucket: one key per agent, holding the
//! [`hive_types::Permission`] set the swarm grants it.
//!
//! **swarm-controller is the only writer**, and creates the bucket when it
//! starts. A missing key or an unset `permissions` field leaves the agent on
//! the compiled-in default (`Permission::DEFAULT`); an empty list grants
//! nothing.
//!
//! **Two readers, each enforcing its own part.** The hive running the agent
//! applies `manage_root_agent` as bind mounts at container start; its grant
//! covers every key of this bucket, read-only, for the reason
//! [`crate::agent_limits`] gives. The agent itself (its harness and subagent
//! daemon) applies the tool permissions; its grant covers only its own key.
//! Both read with one `DIRECT.GET` on the agent's key
//! ([`crate::agent_permissions::key_subject`]).
//!
//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move
//! between hives, and its permissions go with it.
//!
//! Also carries the one-shot import a hive sends of the grants its meta repo
//! held ([`crate::agent_permissions::import_subject`]), answered by the
//! controller.
/// The KV bucket permissions are published into, keyed by agent name.
pub const BUCKET: &str = "agent-permissions";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-permissions";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
const IMPORT_SUBJECT_PREFIX: &str = "$SWARM.permissions-import";
/// The subject `hive` sends its import on. One per hive, so the hive's grant
/// names only its own, and the controller takes the hive from the subject
/// rather than from the payload.
#[must_use]
pub fn import_subject(hive: &str) -> String {
format!("{IMPORT_SUBJECT_PREFIX}.{hive}")
}
/// Every [`import_subject`] at once, for the controller's subscription.
#[must_use]
pub fn import_subject_wildcard() -> String {
import_subject("*")
}
/// The hive an [`import_subject`] names, or `None` for any other subject.
#[must_use]
pub fn hive_from_import_subject(subject: &str) -> Option<&str> {
let hive = subject
.strip_prefix(IMPORT_SUBJECT_PREFIX)?
.strip_prefix('.')?;
(!hive.is_empty() && !hive.contains('.')).then_some(hive)
}
/// How long a hive waits for the controller's answer to an import, and the
/// queue lets the controller answer.
pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// One agent's permissions: the value under `BUCKET`/`<agent>`. `None` is
/// "not set by the swarm", which a hive reads as its default.
///
/// No `deny_unknown_fields`: a field a newer controller adds must not cost an
/// older hive the fields it knows.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<hive_types::Permission>>,
}
/// What a hive sends once: the permissions its meta repo held, by agent.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Import {
pub permissions: std::collections::BTreeMap<String, Vec<hive_types::Permission>>,
}
/// The controller's answer to an [`Import`].
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ImportReply {
/// Every agent was handled. `written` had no key and got the hive's value;
/// `kept` already had a key, which was left alone; `skipped` is not an
/// agent the sending hive's wanted state declares.
Imported {
written: Vec<String>,
kept: Vec<String>,
skipped: Vec<String>,
},
/// Nothing is known to be written; the hive sends again later.
Failed { error: String },
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grants in `swarm-nats-auth` are built from.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-permissions.iris");
assert_eq!(import_subject("pr1ma"), "$SWARM.permissions-import.pr1ma");
assert_eq!(import_subject_wildcard(), "$SWARM.permissions-import.*");
}
#[test]
fn the_hive_is_read_back_from_its_import_subject_only() {
assert_eq!(
hive_from_import_subject(&import_subject("pr1ma")),
Some("pr1ma")
);
for other in [
"$SWARM.permissions-import",
"$SWARM.permissions-import.",
"$SWARM.permissions-import.a.b",
"$SWARM.permissions-importx.pr1ma",
"$SWARM.deploy.pr1ma",
] {
assert_eq!(hive_from_import_subject(other), None, "{other}");
}
}
/// Unset is left out on the wire, `{}` is unset, and an empty list stays
/// an empty list: "grants nothing" is not "not set".
#[test]
fn unset_and_empty_are_different_values() {
use hive_types::Permission;
assert_eq!(
serde_json::to_string(&AgentPermissions::default()).unwrap(),
"{}"
);
let none: AgentPermissions = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
assert_eq!(none.permissions, Some(Vec::new()));
let ruth: AgentPermissions =
serde_json::from_str(r#"{"permissions":["manage_root_agent"]}"#).unwrap();
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
}
}

View file

@ -0,0 +1,191 @@
//! An agent's own terminal: the stream that keeps its rows, and the consumer
//! a reader replays them through.
//!
//! The agent publishes its classified terminal rows on
//! [`crate::term_subject`] with a plain core publish, under its own queue
//! credential, which may publish there and nothing else of these streams.
//! `swarm-controller` creates one stream per agent with the fixed config
//! `swarm_queue_client::agent_term::open_or_create` spells, so the queue keeps
//! the rows a reader who attaches later replays.
//!
//! The stream captures exactly one literal subject, `$SWARM.term.<agent>`.
//! [`crate::subagent_term`]'s stream captures `$SWARM.term.<agent>.sub.>`,
//! which only matches subjects with at least two more tokens, so no subject
//! is in both and the two streams never overlap. The `JetStream` server refuses
//! to create a stream whose subjects overlap another's.
//!
//! The names live here because three crates must agree on them: the
//! publisher in `hive-agent`, `swarm-controller`, and the reader's consumer
//! grant in `swarm-nats-auth`.
/// The token between the prefix and the agent in [`stream_name`]. It differs
/// from `term-sub-` at the same position, so no agent name makes this
/// stream's name equal another agent's subagent stream.
const STREAM_KIND: &str = "main";
/// How long the stream keeps a row. The same window as the subagent stream.
pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
/// How many of the newest rows a reader that attaches fresh is replayed
/// before the live tail. The agent's own web UI loads the same number of
/// events as its first history page.
pub const REPLAY_ROWS: u64 = 100;
/// How often the server sends an idle replay consumer a heartbeat. Two missed
/// in a row end the reader's row stream, so a consumer the server dropped,
/// as on a restart, ends the SSE response and the browser reconnects.
pub const IDLE_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(5);
/// The stream holding `agent`'s terminal rows: `term-main-<agent>`.
#[must_use]
pub fn stream_name(agent: &str) -> String {
format!("term-{STREAM_KIND}-{agent}")
}
/// The one subject [`stream_name`]'s stream captures.
#[must_use]
pub fn stream_subject(agent: &str) -> String {
crate::term_subject(agent)
}
/// The reader's grant to create a replay consumer: on any stream, under any
/// consumer name, in the extended create form whose last tokens are the
/// consumer's filter.
///
/// The server refuses a create whose payload filter differs from those
/// tokens, so a consumer made under this grant delivers only rows on
/// subjects below `$SWARM.term`. The grant's last `*` also matches a literal
/// `*` or `>` token, so besides one agent's [`crate::term_subject`] it admits
/// `$SWARM.term.*` and `$SWARM.term.>`, which take in every agent's and every
/// subagent's terminal. The bare `CONSUMER.CREATE.<stream>` form, whose
/// filter rides only in the payload, is not covered.
#[must_use]
pub fn consumer_create_grant() -> String {
format!(
"$JS.API.CONSUMER.CREATE.*.*.{}",
crate::term_subject_wildcard()
)
}
/// The first stream sequence to replay from.
///
/// `resume_after` is the last sequence the reader already holds, from a
/// reconnecting browser's `Last-Event-ID`; the replay continues right after
/// it. A fresh reader gets the newest [`REPLAY_ROWS`] below `last_sequence`,
/// the stream's newest row when it was read. A sequence the stream has since
/// discarded starts the consumer at the oldest row it still has.
///
/// A `resume_after` above `last_sequence` names no row this stream ever held,
/// as after the stream was created again, and is read as a fresh reader: the
/// server accepts a start past the end and delivers nothing until the stream
/// reaches it.
#[must_use]
pub fn replay_start(last_sequence: u64, resume_after: Option<u64>) -> u64 {
match resume_after {
Some(seen) if seen <= last_sequence => seen + 1,
_ => last_sequence.saturating_sub(REPLAY_ROWS - 1).max(1),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Token-wise NATS subject overlap: whether some literal subject matches
/// both patterns.
fn overlaps(a: &str, b: &str) -> bool {
let (mut a, mut b) = (a.split('.'), b.split('.'));
loop {
match (a.next(), b.next()) {
(Some(">"), Some(_)) | (Some(_), Some(">")) | (None, None) => return true,
(None, _) | (_, None) => return false,
(Some(x), Some(y)) if x != "*" && y != "*" && x != y => return false,
_ => {}
}
}
}
#[test]
fn names_match_the_publisher_and_the_grant() {
assert_eq!(stream_name("iris"), "term-main-iris");
assert_eq!(stream_subject("iris"), "$SWARM.term.iris");
assert_eq!(
consumer_create_grant(),
"$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*"
);
}
/// The server refuses to create a stream whose subjects overlap
/// another's, so this stream must share no subject with any agent's
/// subagent stream, its own agent's included.
#[test]
fn the_subject_is_disjoint_from_every_subagent_stream() {
for other in ["iris", "argus", "sub", "iris-2"] {
assert!(
!overlaps(
&stream_subject("iris"),
&crate::subagent_term::stream_subjects(other)
),
"term-main-iris overlaps term-sub-{other}"
);
}
// Control: the checker does report a real overlap.
assert!(overlaps(
"$SWARM.term.iris.>",
&crate::subagent_term::stream_subjects("iris")
));
assert!(overlaps(&stream_subject("iris"), "$SWARM.term.*"));
}
/// No agent name makes this stream's name another agent's subagent
/// stream: an agent may be called `sub-x`.
#[test]
fn the_name_never_collides_with_a_subagent_stream() {
for (agent, other) in [("sub-x", "x"), ("x", "sub-x"), ("main-x", "x")] {
assert_ne!(stream_name(agent), crate::subagent_term::stream_name(other));
}
}
/// The create the controller sends is one the grant covers, and every
/// filter the grant admits stays below `$SWARM.term`.
#[test]
fn the_grant_admits_only_filters_below_the_terminal_prefix() {
let grant = consumer_create_grant();
let create = |filter: &str| format!("$JS.API.CONSUMER.CREATE.term-main-iris.r1.{filter}");
assert!(overlaps(&grant, &create(&stream_subject("iris"))));
// The width the doc states: a literal wildcard as the last token.
assert!(overlaps(&grant, &create("$SWARM.term.>")));
for other in [
crate::subagent_term::subject("iris", "scout"),
crate::subagent_term::stream_subjects("iris"),
// `swarm_queue_client::agent_state_subject("iris")`, whose value
// that crate's tests pin.
"$SWARM.agent-state.iris".to_owned(),
] {
assert!(!overlaps(&grant, &create(&other)), "{other}");
}
assert!(!overlaps(&grant, "$JS.API.CONSUMER.CREATE.term-main-iris"));
}
#[test]
fn a_fresh_reader_replays_the_newest_rows() {
assert_eq!(replay_start(1000, None), 1000 - REPLAY_ROWS + 1);
assert_eq!(replay_start(5, None), 1);
assert_eq!(replay_start(0, None), 1);
}
#[test]
fn a_reconnecting_reader_resumes_after_its_last_row() {
assert_eq!(replay_start(1000, Some(990)), 991);
assert_eq!(replay_start(1000, Some(1000)), 1001);
}
/// An id past the stream's newest row, as after the stream was created
/// again, replays like a fresh reader instead of waiting for that number.
#[test]
fn an_id_past_the_newest_row_replays_as_a_fresh_reader() {
assert_eq!(replay_start(5, Some(99)), replay_start(5, None));
assert_eq!(replay_start(1000, Some(1001)), replay_start(1000, None));
assert_eq!(replay_start(0, Some(7)), 1);
}
}

View file

@ -9,10 +9,50 @@ pub mod agent_icon;
pub mod agent_inference;
pub mod agent_limits;
pub mod agent_login;
pub mod agent_permissions;
pub mod agent_schedules;
pub mod agent_status;
pub mod agent_term;
pub mod builder;
pub mod claude_login;
pub mod notices;
pub mod review_classifier;
pub mod subagent_term;
pub mod wanted;
/// The subject `agent` publishes its terminal rows on. One leaf per agent, so
/// a subscriber can follow one agent without filtering the swarm's whole
/// terminal traffic.
///
/// Shared by the publisher (`hive-agent`), the relay that streams it to
/// swarm-ui (`swarm-controller`) and the queue's subscribe grant for readers
/// (`swarm-nats-auth`), which spells [`term_subject_wildcard`] — so a rename
/// moves all three together.
#[must_use]
pub fn term_subject(agent: &str) -> String {
format!("{TERM_SUBJECT_PREFIX}.{agent}")
}
/// Every [`term_subject`] at once: the reader's subscribe grant, which has no
/// roster to enumerate agents from.
#[must_use]
pub fn term_subject_wildcard() -> String {
term_subject("*")
}
/// Shared by [`term_subject`] and [`subagent_term`], whose subjects sit under
/// the same prefix.
pub const TERM_SUBJECT_PREFIX: &str = "$SWARM.term";
#[cfg(test)]
mod tests {
use super::*;
/// The names the publishers, the relays and the queue's grant all spell.
#[test]
fn term_subjects_are_the_agreed_names() {
assert_eq!(term_subject("mara"), "$SWARM.term.mara");
assert_eq!(term_subject_wildcard(), "$SWARM.term.*");
assert_eq!(subagent_term::subject_wildcard(), "$SWARM.term.*.sub.*");
}
}

View file

@ -0,0 +1,152 @@
//! Subagent terminals: the subject each subagent's rows go to, and the
//! stream that keeps them.
//!
//! An agent's subagent daemon publishes each subagent's classified terminal
//! rows on `$SWARM.term.<agent>.sub.<subagent>`, under the agent's own queue
//! credential. The queue grants that credential publish on this subject
//! family and no `JetStream` subject.
//!
//! The stream lets the swarm list an agent's subagents: subagents
//! are spawned on demand under caller-chosen names, and the set of subjects
//! the stream holds is the list. `swarm-controller` creates one stream per
//! agent with the fixed config
//! `swarm_queue_client::subagent_term::open_or_create` spells, and takes the
//! list from its subject counts ([`crate::subagent_term::subagent_names`]). A
//! reader attaching to one subagent's terminal replays that subagent's newest
//! rows from it.
//!
//! The name and the subject live here because they must agree across the
//! publisher in `hive-subagent-mcp`, `swarm-controller`, the queue grant in
//! `swarm-nats.nix`, whose `{agent}` template spells the same subjects, and
//! the reader's consumer grant in `swarm-nats-auth`.
use crate::TERM_SUBJECT_PREFIX;
/// The token between the agent and the subagent name. A subagent subject has
/// two more tokens than the agent's own `$SWARM.term.<agent>`, so neither
/// family's subscriber receives the other's rows.
const SUBAGENT_TOKEN: &str = "sub";
/// How long the stream keeps a row. The subagent list is the subjects with a
/// row inside this window, so a subagent drops off the list this long after
/// its last output.
pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
/// The stream holding `agent`'s subagent rows: `term-sub-<agent>`.
#[must_use]
pub fn stream_name(agent: &str) -> String {
format!("term-sub-{agent}")
}
/// The subject `subagent`'s rows are published on.
#[must_use]
pub fn subject(agent: &str, subagent: &str) -> String {
format!("{TERM_SUBJECT_PREFIX}.{agent}.{SUBAGENT_TOKEN}.{subagent}")
}
/// Every [`subject`] at once: the reader's subscribe grant, which has no
/// roster to enumerate agents or subagents from.
#[must_use]
pub fn subject_wildcard() -> String {
subject("*", "*")
}
/// The reader's grant to create a replay consumer on one subagent's rows:
/// the extended create form of [`crate::agent_term::consumer_create_grant`],
/// on any stream and under any consumer name, with a [`subject`] as the
/// filter tokens the server checks the payload against. The last `*` also
/// matches a literal `*` or `>`, so it admits `$SWARM.term.<agent>.sub.>`
/// too, and nothing outside [`subject_wildcard`]'s family.
#[must_use]
pub fn consumer_create_grant() -> String {
format!("$JS.API.CONSUMER.CREATE.*.*.{}", subject_wildcard())
}
/// Every subject [`stream_name`]'s stream captures, which is also the publish
/// grant's shape.
#[must_use]
pub fn stream_subjects(agent: &str) -> String {
format!("{TERM_SUBJECT_PREFIX}.{agent}.{SUBAGENT_TOKEN}.>")
}
/// The subagent a stream subject names, or `None` for a subject that is not
/// exactly one subagent token under `agent`'s family.
#[must_use]
pub fn subagent_from_subject<'a>(agent: &str, subject: &'a str) -> Option<&'a str> {
let name = subject
.strip_prefix(TERM_SUBJECT_PREFIX)?
.strip_prefix('.')?
.strip_prefix(agent)?
.strip_prefix('.')?
.strip_prefix(SUBAGENT_TOKEN)?
.strip_prefix('.')?;
(!name.is_empty() && !name.contains('.')).then_some(name)
}
/// The subagents named by a stream's subjects, sorted and deduplicated.
/// Subjects outside `agent`'s family are skipped.
#[must_use]
pub fn subagent_names<'a>(agent: &str, subjects: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut names: Vec<String> = subjects
.into_iter()
.filter_map(|s| subagent_from_subject(agent, s))
.map(str::to_owned)
.collect();
names.sort_unstable();
names.dedup();
names
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grant in `swarm-nats.nix` spells with `{agent}`.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(stream_name("iris"), "term-sub-iris");
assert_eq!(subject("iris", "scout"), "$SWARM.term.iris.sub.scout");
assert_eq!(stream_subjects("iris"), "$SWARM.term.iris.sub.>");
assert_eq!(
consumer_create_grant(),
"$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*.sub.*"
);
}
#[test]
fn a_subject_reads_back_as_its_subagent() {
assert_eq!(
subagent_from_subject("iris", &subject("iris", "scout")),
Some("scout")
);
}
/// The agent's own terminal, another agent's subagent, and anything
/// deeper than one token are not subagents of `iris`.
#[test]
fn other_subjects_name_no_subagent() {
for s in [
"$SWARM.term.iris",
"$SWARM.term.h1.iris",
"$SWARM.term.iris.sub",
"$SWARM.term.iris.sub.",
"$SWARM.term.iris.sub.a.b",
"$SWARM.term.iris-2.sub.scout",
"$SWARM.term.argus.sub.scout",
"$SWARM.agent-state.iris.sub.scout",
] {
assert_eq!(subagent_from_subject("iris", s), None, "{s}");
}
}
#[test]
fn the_list_is_sorted_unique_and_scoped_to_the_agent() {
let subjects = [
"$SWARM.term.iris.sub.zed",
"$SWARM.term.iris.sub.alpha",
"$SWARM.term.argus.sub.other",
"$SWARM.term.iris.sub.alpha",
];
assert_eq!(subagent_names("iris", subjects), ["alpha", "zed"]);
}
}