//! Tool-group + capability permission endpoints for the dashboard. //! //! Read endpoints return the full set of known groups/capabilities plus //! descriptions and the per-agent assignment map (the UI never hard-codes //! the lists). Write endpoints validate, then enqueue a `PermChange` so the //! JSON file write is serialised through the FIFO rebuild worker. use axum::{ extract::{Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use problem_details::ProblemDetails; use super::{AppState, Ident, guard_agent_name, strip_container_prefix}; #[derive(Serialize)] pub(super) struct ToolGroupsSnapshot { /// Ordered list of all known tool-group names. Drives the column /// headers in the capabilities table — the UI does not hard-code them. groups: Vec<&'static str>, /// Short description for each group name. Keys match `groups` entries. descriptions: std::collections::BTreeMap<&'static str, &'static str>, /// Per-agent *explicit* assignment map. Absent agents use the role /// default — the UI uses presence here to badge an agent "(default)". assignments: std::collections::BTreeMap>, /// Every agent the operator can manage (live roster ∪ explicit keys), /// sorted. The UI lists rows from this so default-perms agents always /// appear without depending on a separately-loaded container list. agents: Vec, /// Per-agent *effective* groups: the explicit entry when present, else /// the role default the harness actually applies. Drives the checkbox /// state so default agents show their real groups, not blank. effective: std::collections::BTreeMap>, } pub(super) async fn get_tool_groups( State(state): State, ) -> axum::Json { let groups = hive_sh4re::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect(); let descriptions = hive_sh4re::ToolGroup::ALL .iter() .map(|g| (g.as_str(), g.description())) .collect(); let assignments = crate::tool_groups::read(); let roster = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name); let (agents, effective) = roster_and_effective(roster, &assignments, &tool_group_default_names()); axum::Json(ToolGroupsSnapshot { groups, descriptions, assignments, agents, effective, }) } /// The role-default tool-group names the harness falls back to for an /// agent with no explicit entry (`ToolGroup::AGENT_DEFAULT`). Shared by /// the HTTP snapshot and the SSE emit so the effective values match what /// the container actually runs with. #[must_use] pub(crate) fn tool_group_default_names() -> Vec<&'static str> { hive_sh4re::ToolGroup::AGENT_DEFAULT .iter() .map(|g| g.as_str()) .collect() } /// Build the `(agents, effective)` pair for a permissions snapshot. /// `agents` is the sorted union of the live roster and any agent that /// already has an explicit entry; `effective[agent]` is the explicit /// assignment when present, else `default` (the role fallback). Shared by /// the capabilities + tool-groups snapshots on both the HTTP and SSE /// paths so the two surfaces never drift. #[must_use] pub(crate) fn roster_and_effective( roster: impl IntoIterator, explicit: &std::collections::BTreeMap>, default: &[&'static str], ) -> (Vec, std::collections::BTreeMap>) { let mut names: std::collections::BTreeSet = roster.into_iter().collect(); names.extend(explicit.keys().cloned()); let agents: Vec = names.into_iter().collect(); let effective = agents .iter() .map(|a| { let v = explicit .get(a) .cloned() .unwrap_or_else(|| default.iter().map(|s| (*s).to_owned()).collect::>()); (a.clone(), v) }) .collect(); (agents, effective) } #[derive(Deserialize)] pub(super) struct SetToolGroupsBody { groups: Vec, } pub(super) async fn post_tool_groups( State(state): State, AxumPath(name): AxumPath, axum::Json(body): axum::Json, ) -> Result { let logical = strip_container_prefix(&name); // `guard_agent_name` yields a ready-made rejection `Response`; pass it // through as `Ok` (axum sends it verbatim) rather than re-deriving a // `ProblemDetails` — the guard is shared with `-> Response` handlers. if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } // Validate group names before queuing — fail fast so the operator // sees the error immediately rather than waiting for the worker. if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("invalid tool-groups for {logical}: {e}"))); } // Submit a PermChange DAG: the JSON file write commits under // META_LOCK inside the WritePermFile node, so concurrent // batch-apply actions for different agents never race on the // shared tool-groups.json. crate::job_queue::submit::perm_change( &state.coord, &logical, crate::job_queue::Source::Manual, "tool-group change via permissions UI".to_owned(), crate::job_queue::PermPayload::ToolGroups { groups: body.groups.clone(), }, ); tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard"); Ok((StatusCode::OK, "ok").into_response()) } #[derive(Serialize)] pub(super) struct CapabilitiesSnapshot { /// Ordered list of all known capability names. Drives the column /// headers in the capabilities table — the UI does not hard-code them. caps: Vec<&'static str>, /// Short description for each capability name. Keys match `caps` entries. descriptions: std::collections::BTreeMap<&'static str, &'static str>, /// Per-agent *explicit* capability grant map. Absent agents have no /// extra caps — the UI uses presence here to badge "(default)". assignments: std::collections::BTreeMap>, /// Every agent the operator can manage (live roster ∪ explicit keys), /// sorted — so default agents always list. agents: Vec, /// Per-agent *effective* caps: explicit entry when present, else the /// default (no caps). Keeps the snapshot shape symmetric with /// tool-groups; the default here is always empty. effective: std::collections::BTreeMap>, } pub(super) async fn get_capabilities( State(state): State, ) -> axum::Json { use hive_sh4re::Capability; let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() .map(|c| (c.as_str(), c.description())) .collect(); let assignments = crate::capabilities::read(); let roster = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name); // Capability default is "no extra caps" — empty default slice. let (agents, effective) = roster_and_effective(roster, &assignments, &[]); axum::Json(CapabilitiesSnapshot { caps, descriptions, assignments, agents, effective, }) } #[derive(Deserialize)] pub(super) struct SetCapabilitiesBody { caps: Vec, } pub(super) async fn post_capabilities( State(state): State, AxumPath(name): AxumPath, axum::Json(body): axum::Json, ) -> Result { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } let known: Vec<&str> = hive_sh4re::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); for cap in &body.caps { if !known.contains(&cap.as_str()) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("unknown capability: {cap}"))); } } // Submit a PermChange DAG: the JSON file write commits under // META_LOCK inside the WritePermFile node, so concurrent // batch-apply actions for different agents never race on the // shared capabilities.json. crate::job_queue::submit::perm_change( &state.coord, &logical, crate::job_queue::Source::Manual, "capability change via dashboard".to_owned(), crate::job_queue::PermPayload::Capabilities { caps: body.caps.clone(), }, ); tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard"); Ok((StatusCode::OK, "ok").into_response()) } /// One agent's slice of a batch permission change. Sparse: an omitted /// field leaves that perm-type untouched, an empty array clears it, a /// populated array fully replaces it (same replace semantics as the /// per-agent endpoints). #[derive(Deserialize)] pub(super) struct PermChangeBody { agent: String, #[serde(default)] tool_groups: Option>, #[serde(default)] capabilities: Option>, } #[derive(Deserialize)] pub(super) struct BatchPermsBody { changes: Vec, } /// A validated, non-empty change staged for enqueue: /// `(logical agent, new groups?, new caps?)`. type StagedPerm = (String, Option>, Option>); /// Batch permission apply — `POST /api/permissions`. The save-all /// permissions UI sends only the perm-types that actually changed per /// agent; each affected agent gets ONE combined `PermChange`, so the /// dedup key collapses to `(kind, agent)` and an agent whose caps AND /// groups both changed rebuilds once, not twice. The whole batch is /// atomic: every change is validated up front and on any validation /// error nothing is written or enqueued. pub(super) async fn post_permissions( State(state): State, axum::Json(body): axum::Json, ) -> Result { let known_caps: Vec<&str> = hive_sh4re::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); // Phase 1 — validate everything before touching any file or the // queue, so a bad entry fails the whole POST with zero side effects. // No-op rows (both fields omitted) are skipped, not errors. let mut staged: Vec = Vec::new(); for change in &body.changes { let logical = strip_container_prefix(&change.agent); if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } if let Some(groups) = &change.tool_groups && let Err(e) = crate::tool_groups::validate_groups(groups) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("invalid tool-groups for {logical}: {e}"))); } if let Some(caps) = &change.capabilities { for cap in caps { if !known_caps.contains(&cap.as_str()) { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("unknown capability for {logical}: {cap}"))); } } } if change.tool_groups.is_some() || change.capabilities.is_some() { staged.push(( logical, change.tool_groups.clone(), change.capabilities.clone(), )); } } // Phase 2 — submit one combined PermChange DAG per affected agent. for (logical, groups, caps) in staged { crate::job_queue::submit::perm_change( &state.coord, &logical, crate::job_queue::Source::Manual, "batch permission change via permissions UI".to_owned(), crate::job_queue::PermPayload::Combined { groups, caps }, ); tracing::info!(agent = %logical, "operator: batch perm change via dashboard"); } Ok((StatusCode::OK, "ok").into_response()) } /// Agent names that have explicit capability/tool-group entries but are /// not in the live container roster AND not in the kept-state directory /// list (i.e. truly gone — renamed or destroyed agents whose JSON entries /// persisted). The client uses this to drive the "stale permission entries" /// sub-section in K3PT ST4T3 without having to fetch three separate /// endpoints and perform set arithmetic on the client side. #[derive(Serialize)] pub(super) struct StalePermsResponse { /// Ghost agent names, sorted. Empty list → no stale entries. stale: Vec, } pub(super) async fn get_stale_permissions( State(state): State, ) -> axum::Json { // Live container names — includes stopped-but-configured containers. let live: std::collections::HashSet = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name) .collect(); // Kept-state directories: every agent that ever had a state dir on disk, // including both live containers (already in `live`) and soft-deleted // tombstones (destroyed but kept). A name present here is either live // or a properly-removed tombstone — neither is a ghost. let kept: std::collections::HashSet = crate::coordinator::Coordinator::kept_state_names() .into_iter() .map(hive_types::Ident::into_string) .collect(); // Known = live roster ∪ kept-state names. let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); // Explicit entries in either JSON file. let caps = crate::capabilities::read(); let tgs = crate::tool_groups::read(); let mut ghost_names: Vec = caps .keys() .chain(tgs.keys()) .filter(|n| !known.contains(n)) .cloned() .collect::>() .into_iter() .collect(); ghost_names.sort(); axum::Json(StalePermsResponse { stale: ghost_names }) } /// Clear all explicit permission entries for a named agent without /// requiring it to exist in the live roster. Used by the P3RM1SS10NS /// tab's "remove" button for agents that have stale explicit entries /// in `tool-groups.json` / `capabilities.json` but are no longer /// running (e.g. an agent that was renamed or destroyed while its /// JSON entries persisted). /// /// Bypasses `guard_agent_name`'s live-roster check intentionally — /// the whole point is to remove entries for non-roster agents. Only /// the format check ([`Ident::parse`]) is applied. No rebuild is /// enqueued (the agent doesn't exist to rebuild); the SSE snapshots /// update the P3RM1SS10NS tab live. pub(super) async fn delete_agent_permissions( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = match Ident::parse(&strip_container_prefix(&name)) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); } }; // Run both removals regardless so we clean up as much as possible // even on partial I/O errors. Collect errors to surface below. let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err(); if let Some(ref e) = tg_err { tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry"); } let cap_err = crate::capabilities::remove_agent(logical.as_str()).err(); if let Some(ref e) = cap_err { tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry"); } // Emit live snapshots even on partial failure so the UI stays as // accurate as possible — the surviving table gets updated immediately. state.coord.emit_tool_groups_snapshot(); state.coord.emit_capabilities_snapshot(); // Surface any I/O error as 500 so the frontend's `!resp.ok` path // fires and the operator sees a meaningful message rather than a // silent "success" followed by the row reappearing unchanged. if let Some(e) = tg_err.or(cap_err) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to remove permission entries for {logical}: {e}"), ) .into_response(); } tracing::info!(agent = %logical, "operator: cleared stale permission entries via dashboard"); (StatusCode::OK, "ok").into_response() } #[cfg(test)] mod tests { use super::roster_and_effective; use std::collections::BTreeMap; #[test] fn effective_unions_roster_and_explicit_and_applies_default() { let mut explicit: BTreeMap> = BTreeMap::new(); explicit.insert("iris".to_owned(), vec!["messaging".to_owned()]); // `ruth` has an explicit entry but isn't in the live roster (e.g. // momentarily not listed) — it must still appear and keep its // explicit value. explicit.insert( "ruth".to_owned(), vec!["messaging".to_owned(), "lifecycle".to_owned()], ); let roster = ["damocles".to_owned(), "iris".to_owned()]; let default = ["messaging", "meta", "inbox", "execution"]; let (agents, effective) = roster_and_effective(roster, &explicit, &default); // Sorted union of roster + explicit keys. assert_eq!(agents, vec!["damocles", "iris", "ruth"]); // Entry-less agent (damocles) → role default. assert_eq!( effective["damocles"], vec!["messaging", "meta", "inbox", "execution"] ); // Explicit entries kept verbatim. assert_eq!(effective["iris"], vec!["messaging"]); assert_eq!(effective["ruth"], vec!["messaging", "lifecycle"]); } #[test] fn empty_default_yields_empty_effective_for_entryless() { let explicit: BTreeMap> = BTreeMap::new(); let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]); assert_eq!(agents, vec!["atlas"]); assert!(effective["atlas"].is_empty()); } }