refactor(permissions): move ghost-perm detection server-side

Add GET /api/permissions/stale endpoint that returns agent names with
explicit capability/tool-group JSON entries but no live container AND
no kept-state tombstone. Ghost detection is now entirely server-side —
one authoritative call, no client-side roster cache, no staleness window.

The previous client-side approach in core.js made three parallel API
calls (GET /api/capabilities, GET /api/tool-groups, GET /api/state) and
filtered the result against a module-level `liveContainerNames` Set
populated only on cold load and form submits. Any container lifecycle
event (spawn, destroy) while core.html was open left `liveContainerNames`
stale, risking a false-positive ghost entry for a live container.

Changes:
- permissions.rs: add `get_stale_permissions` handler + `StalePermsResponse`
  struct. Computes live roster (containers_snapshot), tombstone set
  (Coordinator::kept_state_names), explicit perm names (capabilities::read
  + tool_groups::read), then returns the difference sorted.
- dashboard.rs: register GET /api/permissions/stale.
- core.js: replace the three-call client-side logic in
  `fetchAndRenderStalePerms` with a single fetch to /api/permissions/stale.
  Remove `liveContainerNames` state + its syncFromSnapshot population.
This commit is contained in:
iris 2026-06-27 17:55:47 +02:00
commit 4df286345a
3 changed files with 65 additions and 20 deletions

View file

@ -115,6 +115,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
post(permissions::post_capabilities),
)
.route("/api/permissions", post(permissions::post_permissions))
.route(
"/api/permissions/stale",
get(permissions::get_stale_permissions),
)
.route(
"/api/permissions/{agent}",
axum::routing::delete(permissions::delete_agent_permissions),

View file

@ -312,6 +312,52 @@ pub(super) async fn post_permissions(
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 tombstone
/// 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<String>,
}
pub(super) async fn get_stale_permissions(
State(state): State<AppState>,
) -> axum::Json<StalePermsResponse> {
// Live container names — includes stopped-but-configured containers.
let live: std::collections::HashSet<String> = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name)
.collect();
// Kept-state directories — any name here is a tombstone, not a ghost.
let tombstones: std::collections::HashSet<String> =
crate::coordinator::Coordinator::kept_state_names()
.into_iter()
.collect();
// Known = live roster tombstones.
let known: std::collections::HashSet<&String> =
live.iter().chain(tombstones.iter()).collect();
// Explicit entries in either JSON file.
let caps = crate::capabilities::read();
let tgs = crate::tool_groups::read();
let mut ghost_names: Vec<String> = caps
.keys()
.chain(tgs.keys())
.filter(|n| !known.contains(n))
.cloned()
.collect::<std::collections::HashSet<_>>()
.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