fix(#1661): show default-perms agents with effective values in perms tab

This commit is contained in:
damocles 2026-06-14 20:46:18 +02:00 committed by mara
commit 789ecd86f6
6 changed files with 193 additions and 24 deletions

View file

@ -208,6 +208,16 @@ permission names fetched from the backend. The column list is
authoritative — adding a new tool-group or capability to the backend
requires no UI change; the new column appears automatically.
The snapshot carries `agents` (the full manageable roster — live
containers agents with an explicit entry) and `effective` (per-agent
explicit-or-role-default values) alongside the explicit `assignments`
map. Rows come from `agents` so **agents on defaults always appear**
(not just those with an explicit entry), and checkboxes reflect the
`effective` values so a default agent shows the groups it actually runs
with rather than blank — which also means saving it won't silently
strip those defaults. The `(default)` badge keys off absence from
`assignments` (no explicit entry).
Fetches fire on tab activation (not page-load) to avoid unnecessary
work when the operator never visits this tab. Live mutations from the
rebuild-queue worker are also pushed via the `capabilities_changed` /

View file

@ -50,17 +50,21 @@ export async function fetchAndRenderCapabilities() {
function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments } = data;
const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments.
const agentNames = [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
// Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container explicit
// union for older payloads that don't carry `agents`.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
@ -83,7 +87,9 @@ function renderCapabilities(root, data) {
const tbody = el('tbody');
for (const name of agentNames) {
const assigned = assignments[name] || [];
// Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank.
const assigned = effective[name] || assignments[name] || [];
const tr = el('tr', { class: 'cap-row' });
// Agent name cell.
@ -165,18 +171,20 @@ export async function fetchAndRenderToolGroups() {
function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments } = data;
const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments,
// sorted alphabetically.
const agentNames = [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
// Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container explicit union for older payloads.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
@ -199,8 +207,11 @@ function renderToolGroups(root, data) {
const tbody = el('tbody');
for (const name of agentNames) {
// Explicit assignment or empty = using role default.
const assigned = assignments[name] || [];
// Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps
// them instead of silently stripping the defaults. The "(default)"
// badge still keys off explicit-assignment presence.
const assigned = effective[name] || assignments[name] || [];
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
const tr = el('tr', { class: 'tg-row' });

View file

@ -579,11 +579,19 @@ impl Coordinator {
.map(|c| (c.as_str(), c.description()))
.collect();
let assignments = crate::capabilities::read();
// Best-effort roster (sync path); on a contended cache miss we
// emit explicit keys only — the HTTP refetch fills the rest in.
let roster = self.live_container_names_blocking().unwrap_or_default();
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) =
crate::dashboard::permissions::roster_and_effective(roster, &assignments, &[]);
self.emit_dashboard_event(DashboardEvent::CapabilitiesChanged {
seq: self.next_seq(),
caps,
descriptions,
assignments,
agents,
effective,
});
}
@ -598,11 +606,19 @@ impl Coordinator {
.map(|g| (g.as_str(), g.description()))
.collect();
let assignments = crate::tool_groups::read();
let roster = self.live_container_names_blocking().unwrap_or_default();
let (agents, effective) = crate::dashboard::permissions::roster_and_effective(
roster,
&assignments,
&crate::dashboard::permissions::tool_group_default_names(),
);
self.emit_dashboard_event(DashboardEvent::ToolGroupsChanged {
seq: self.next_seq(),
groups,
descriptions,
assignments,
agents,
effective,
});
}

View file

@ -33,7 +33,7 @@ mod approvals;
mod build_logs;
mod journal;
mod lifecycle_ops;
mod permissions;
pub(crate) mod permissions;
mod questions;
mod reminders;
mod schedules;

View file

@ -21,13 +21,21 @@ pub(super) struct ToolGroupsSnapshot {
groups: Vec<&'static str>,
/// Short description for each group name. Keys match `groups` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent assignment map. Absent agents use the role default
/// (agents: messaging+meta+inbox+execution; manager: all groups).
/// 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<String, Vec<String>>,
/// 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<String>,
/// 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<String, Vec<String>>,
}
pub(super) async fn get_tool_groups(
State(_state): State<AppState>,
State(state): State<AppState>,
) -> axum::Json<ToolGroupsSnapshot> {
let groups = hive_sh4re::ToolGroup::ALL
.iter()
@ -38,13 +46,63 @@ pub(super) async fn get_tool_groups(
.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<Item = String>,
explicit: &std::collections::BTreeMap<String, Vec<String>>,
default: &[&'static str],
) -> (Vec<String>, std::collections::BTreeMap<String, Vec<String>>) {
let mut names: std::collections::BTreeSet<String> = roster.into_iter().collect();
names.extend(explicit.keys().cloned());
let agents: Vec<String> = 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::<Vec<_>>());
(a.clone(), v)
})
.collect();
(agents, effective)
}
#[derive(Deserialize)]
pub(super) struct SetToolGroupsBody {
groups: Vec<String>,
@ -87,12 +145,20 @@ pub(super) struct CapabilitiesSnapshot {
caps: Vec<&'static str>,
/// Short description for each capability name. Keys match `caps` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent capability grant map. Absent agents have no extra caps.
/// Per-agent *explicit* capability grant map. Absent agents have no
/// extra caps — the UI uses presence here to badge "(default)".
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Every agent the operator can manage (live roster explicit keys),
/// sorted — so default agents always list.
agents: Vec<String>,
/// 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<String, Vec<String>>,
}
pub(super) async fn get_capabilities(
State(_state): State<AppState>,
State(state): State<AppState>,
) -> axum::Json<CapabilitiesSnapshot> {
use hive_sh4re::Capability;
let caps = Capability::ALL.iter().map(|c| c.as_str()).collect();
@ -101,10 +167,20 @@ pub(super) async fn get_capabilities(
.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,
})
}
@ -146,3 +222,45 @@ pub(super) async fn post_capabilities(
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities 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<String, Vec<String>> = 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<String, Vec<String>> = BTreeMap::new();
let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]);
assert_eq!(agents, vec!["atlas"]);
assert!(effective["atlas"].is_empty());
}
}

View file

@ -231,8 +231,13 @@ pub enum DashboardEvent {
caps: Vec<&'static str>,
/// Short description for each capability name (tooltip).
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent capability grant map; absent agents have no extra caps.
/// Per-agent *explicit* capability grant map; absent agents have
/// no extra caps (the UI badges those "(default)").
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Sorted roster the operator can manage (live explicit keys).
agents: Vec<String>,
/// Per-agent *effective* caps (explicit-or-default; default empty).
effective: std::collections::BTreeMap<String, Vec<String>>,
},
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
/// Emitted from the rebuild-queue worker after a `PermChange`
@ -244,8 +249,13 @@ pub enum DashboardEvent {
groups: Vec<&'static str>,
/// Short description for each group name (tooltip).
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent assignment map; absent agents use the role default.
/// Per-agent *explicit* assignment map; absent agents use the role
/// default (the UI badges those "(default)").
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Sorted roster the operator can manage (live explicit keys).
agents: Vec<String>,
/// Per-agent *effective* groups (explicit-or-`AGENT_DEFAULT`).
effective: std::collections::BTreeMap<String, Vec<String>>,
},
}
@ -413,12 +423,16 @@ mod tests {
caps: Vec::new(),
descriptions: std::collections::BTreeMap::new(),
assignments: std::collections::BTreeMap::new(),
agents: Vec::new(),
effective: std::collections::BTreeMap::new(),
},
DashboardEvent::ToolGroupsChanged {
seq: 1,
groups: Vec::new(),
descriptions: std::collections::BTreeMap::new(),
assignments: std::collections::BTreeMap::new(),
agents: Vec::new(),
effective: std::collections::BTreeMap::new(),
},
DashboardEvent::AuditEntryAdded {
seq: 1,