diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index d53825a6..d3e6140d 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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. */ diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js index 83ac0684..8f1adc25 100644 --- a/frontend/packages/dashboard/src/permissions.js +++ b/frontend/packages/dashboard/src/permissions.js @@ -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'); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 3c744202..c977ec62 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -115,6 +115,10 @@ pub async fn serve(port: u16, coord: Arc) -> 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), diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 3829eb32..b4d1a8c2 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -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, + AxumPath(name): AxumPath, +) -> 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;