fix(permissions): mark stale agents and allow removing their explicit entries

The P3RM1SS10NS tab showed agents that no longer exist in the live
container roster — e.g. an agent named 'root' that was renamed or
destroyed but still had explicit entries in tool-groups.json and/or
capabilities.json. The roster-union behaviour is intentional for
temporarily-stopped agents, but stale entries from renamed/destroyed
agents are confusing.

Backend (dashboard/permissions.rs):
- New DELETE /api/permissions/{agent} handler that bypasses the live-
  roster guard (intentionally — that's the point). Calls
  tool_groups::remove_agent + capabilities::remove_agent to clear both
  JSON files, then emits live SSE snapshots so the tab updates without
  a page reload. Format-checks the agent name but does not require it to
  be in the containers snapshot.

Frontend (permissions.js):
- renderCapabilities / renderToolGroups now cross-reference agentNames
  against containersState (the live roster, already imported). Agents
  not in the live roster get an isStale flag.
- Stale rows get a '(not running)' label and a '✕ remove' button that
  calls clearStaleAgent() — a new async helper that DELETEs the stale
  entry and re-fetches both perm tables.
- Non-stale agents without explicit assignments still get '(default)'.

CSS (dashboard.css):
- .perm-row-stale (reduced opacity), .perm-stale-label (muted small
  text), .perm-remove-btn (small red-bordered button) + disabled state.
This commit is contained in:
iris 2026-06-27 13:35:06 +02:00 committed by mara
commit a8fb33e2ee
4 changed files with 116 additions and 6 deletions

View file

@ -902,6 +902,28 @@ footer .banner-thin {
}
.perm-save-err { color: var(--red); }
/* Stale permission entry (agent not in the live roster) */
.perm-row-stale td { opacity: 0.7; }
.perm-stale-label {
font-size: 0.75em;
color: var(--muted);
margin-left: 0.4em;
}
.perm-remove-btn {
margin-left: 0.5em;
padding: 0.05em 0.5em;
font-size: 0.75em;
color: var(--red);
background: transparent;
border: 1px solid currentColor;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
opacity: 0.8;
}
.perm-remove-btn:hover { opacity: 1; }
.perm-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* scheduled prompts tab
Creation form at the top, list of queued schedule cards below. */

View file

@ -122,11 +122,23 @@ function renderCapabilities(root, data) {
// 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', 'data-agent': name });
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'cap-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
// Agent name cell.
tr.append(el('td', { class: 'cap-agent-col' },
el('span', { class: 'cap-agent-name' }, name)));
const nameTd = el('td', { class: 'cap-agent-col' });
nameTd.append(el('span', { class: 'cap-agent-name' }, name));
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
}
tr.append(nameTd);
// One checkbox per capability.
for (const c of caps) {
@ -214,12 +226,22 @@ function renderToolGroups(root, data) {
// 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', 'data-agent': name });
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'tg-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
// Agent name cell.
const nameTd = el('td', { class: 'tg-agent-col' });
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
if (!hasExplicit) {
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
} else if (!hasExplicit) {
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
}
tr.append(nameTd);
@ -300,6 +322,35 @@ function updateSaveBar() {
btn.textContent = n === 0 ? 'save all' : `save all (${n} agent${n === 1 ? '' : 's'})`;
}
// Remove all explicit permission entries for a stale (non-running)
// agent. Calls DELETE /api/permissions/{agent}, which bypasses the
// roster guard so the stale entries can be cleaned up even though the
// agent isn't in the live container list. Re-fetches both tables after
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
// Disable the row's remove button while the request is in flight to
// prevent a double-submit.
const btn = sectionRoot
? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`)
: null;
if (btn) btn.disabled = true;
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
if (!resp.ok) {
const text = await resp.text().catch(() => resp.status);
setSaveNote('failed to remove ' + name + ': ' + text, true);
if (btn) btn.disabled = false;
return;
}
} catch (err) {
setSaveNote('failed to remove ' + name + ': ' + err, true);
if (btn) btn.disabled = false;
return;
}
// Re-fetch both sections so the stale row disappears.
await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]);
}
function clearSaveStatus() {
const note = $('perm-save-note');
if (note) { note.textContent = ''; note.classList.remove('perm-save-err'); }

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/{agent}",
axum::routing::delete(permissions::delete_agent_permissions),
)
.route(
"/api/schedules",
get(schedules::api_schedules).post(schedules::post_schedule_new),

View file

@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use problem_details::ProblemDetails;
use super::{AppState, guard_agent_name, strip_container_prefix};
use super::{AppState, guard_agent_name, strip_container_prefix, validate_agent_name};
#[derive(Serialize)]
pub(super) struct ToolGroupsSnapshot {
@ -312,6 +312,39 @@ pub(super) async fn post_permissions(
Ok((StatusCode::OK, "ok").into_response())
}
/// 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 (`validate_agent_name`) 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<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
let logical = strip_container_prefix(&name);
if let Some(reason) = validate_agent_name(&logical) {
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
}
if let Err(e) = crate::tool_groups::remove_agent(&logical) {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
}
if let Err(e) = crate::capabilities::remove_agent(&logical) {
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
}
// Emit live snapshots so the P3RM1SS10NS tab updates immediately.
state.coord.emit_tool_groups_snapshot();
state.coord.emit_capabilities_snapshot();
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;