Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31433da3aa | ||
|
|
a8fb33e2ee |
4 changed files with 137 additions and 6 deletions
|
|
@ -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. */
|
||||
|
||||
|
|
|
|||
|
|
@ -122,11 +122,26 @@ 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 });
|
||||
// `containersState` is keyed from `nixos-container list`, which
|
||||
// includes stopped-but-configured containers — so a temporarily-stopped
|
||||
// agent is NOT stale. Only destroyed/renamed agents are absent here.
|
||||
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 +229,25 @@ 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 });
|
||||
// `containersState` is keyed from `nixos-container list`, which
|
||||
// includes stopped-but-configured containers — only destroyed/renamed
|
||||
// agents are absent.
|
||||
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 +328,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'); }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,54 @@ 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();
|
||||
}
|
||||
// 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).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).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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue