Merge pull request #130 from MervinPraison/claude/issue-127-20260617-0955

feat: implement exec approval modal (B-01) for OpenClaw UI parity
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-23 11:39:10 +01:00
commit 56181c8532
3 files changed
+247 -2

No files matched your search

+15
View File
@@ -320,6 +320,21 @@ function handleStoreEvent(
break
}
case 'approval_required':
case 'approval_pending':
case 'run_paused': {
// Check if this is an approval event via extra_data
const extra = event.extra_data as Record<string, unknown> | undefined
if (extra?.type === 'approval_required') {
// Emit custom event for dashboard to handle
const approvalData = extra
window.dispatchEvent(new CustomEvent('aiui:approval-required', {
detail: approvalData
}))
}
break
}
case 'end':
case 'done':
case 'run_cancelled':
+100 -2
View File
@@ -144,6 +144,75 @@ def _stream_event_to_run_event(stream_event) -> Optional[RunEvent]:
return None
# ---------------------------------------------------------------------------
# Approval helpers
# ---------------------------------------------------------------------------
# Module-level cache for approval manager
_approval_manager_cache = None
_approval_manager_load_attempted = False
def _get_approval_manager_cached():
"""Lazy load and cache the approval manager."""
global _approval_manager_cache, _approval_manager_load_attempted
if not _approval_manager_load_attempted:
try:
from praisonaiui.features.approvals import get_approval_manager
_approval_manager_cache = get_approval_manager()
except ImportError:
_approval_manager_cache = None
_approval_manager_load_attempted = True
return _approval_manager_cache
# High-risk tools that should require approval
HIGH_RISK_TOOLS = {
"execute_command", "run_shell", "bash", "shell", "cmd",
"write_file", "delete_file", "modify_file",
"send_email", "make_api_call", "http_request",
"database_query", "sql_execute",
}
def _should_check_approval(tool_name: str) -> bool:
"""Check if a tool requires approval before execution."""
if not tool_name:
return False
# Check if tool is in high-risk list
tool_lower = tool_name.lower()
return any(risk in tool_lower for risk in HIGH_RISK_TOOLS)
def _get_tool_risk_level(tool_name: str) -> str:
"""Determine risk level of a tool."""
if not tool_name:
return "medium"
tool_lower = tool_name.lower()
if any(word in tool_lower for word in ["delete", "remove", "drop", "destroy"]):
return "critical"
if any(word in tool_lower for word in ["write", "modify", "execute", "send", "sql"]):
return "high"
if any(word in tool_lower for word in ["read", "get", "list", "search"]):
return "low"
return "medium"
def _request_tool_approval(tool_name: str, arguments: Any, agent_name: str, risk_level: str) -> Optional[str]:
"""Request approval for tool execution via the approval feature."""
try:
# Import is at module level via _get_approval_manager_cached
mgr = _get_approval_manager_cached()
if mgr is None:
return None
entry = mgr.request_approval({
"tool_name": tool_name,
"arguments": arguments,
"agent_name": agent_name,
"risk_level": risk_level,
"description": f"Tool {tool_name} requires approval for execution",
})
return entry.get("id")
except Exception as e:
logger.debug("Failed to request approval: %s", e)
return None
# ---------------------------------------------------------------------------
# Hook → RunEvent bridge
# ---------------------------------------------------------------------------
@@ -154,11 +223,40 @@ def _hook_event_to_run_events(hook_event_name: str, event_data) -> List[RunEvent
events = []
if hook_event_name == "before_tool":
tool_name = getattr(event_data, "tool_name", None)
args = getattr(event_data, "arguments", None)
# Check if this tool requires approval
should_check_approval = _should_check_approval(tool_name)
if should_check_approval:
# Create approval request
approval_id = _request_tool_approval(
tool_name=tool_name,
arguments=args,
agent_name=getattr(event_data, "agent_name", None),
risk_level=_get_tool_risk_level(tool_name),
)
if approval_id:
# Emit approval required event - using RUN_PAUSED as closest match
# The extra_data contains the approval info for frontend handling
events.append(
RunEvent(
type=RunEventType.RUN_PAUSED,
extra_data={
"type": "approval_required",
"id": approval_id,
"tool_name": tool_name,
"arguments": args,
"risk_level": _get_tool_risk_level(tool_name),
}
)
)
events.append(
RunEvent(
type=RunEventType.TOOL_CALL_STARTED,
name=getattr(event_data, "tool_name", None),
args=getattr(event_data, "arguments", None),
name=tool_name,
args=args,
)
)
elif hook_event_name == "after_tool":
@@ -9,6 +9,123 @@
// Record load time for overview stats
window.__aiuiLoadTime = window.__aiuiLoadTime || Date.now();
// ── Approval Modal System ────────────────────────────────────────────
// Handles approval_required events from SSE stream
let approvalModalShown = false;
let pendingApprovals = [];
let approvalSSESource = null;
function showApprovalModal(approval) {
if (approvalModalShown) {
pendingApprovals.push(approval);
return;
}
approvalModalShown = true;
const overlay = document.createElement('div');
overlay.className = 'db-modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s';
const modal = document.createElement('div');
modal.className = 'db-approval-modal';
modal.style.cssText = 'background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:12px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;animation:slideIn 0.2s;backdrop-filter:blur(10px)';
const riskIcons = {low: '✅', medium: '⚠️', high: '🟠', critical: '🔴'};
const icon = riskIcons[approval.risk_level] || '⚠️';
modal.innerHTML = `
<h2 style="margin:0 0 16px;font-size:20px;font-weight:600;color:var(--db-text)">Tool Execution Approval Required</h2>
<div style="padding:16px;background:rgba(255,255,255,0.03);border-radius:8px;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<span style="font-size:24px">${icon}</span>
<div>
<div style="font-weight:500;font-size:16px">${approval.tool_name || 'Unknown Tool'}</div>
<div style="font-size:13px;color:var(--db-text-dim);margin-top:4px">Risk Level: ${approval.risk_level || 'medium'}</div>
</div>
</div>
${approval.agent_name ? `<div style="font-size:13px;color:var(--db-text-dim);margin-top:8px">Agent: ${approval.agent_name}</div>` : ''}
${approval.description ? `<div style="font-size:13px;margin-top:8px">${approval.description}</div>` : ''}
${approval.arguments ? `<details style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--db-text-dim)">Arguments</summary><pre style="font-size:11px;margin:8px 0 0;padding:8px;background:rgba(0,0,0,0.2);border-radius:4px;overflow-x:auto">${JSON.stringify(approval.arguments, null, 2)}</pre></details>` : ''}
</div>
<div style="margin-bottom:16px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px">
<input type="checkbox" id="approval-always" style="cursor:pointer">
<span>Always ${approval.risk_level === 'critical' ? 'deny' : 'allow'} this tool</span>
</label>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end">
<button id="approval-deny" style="padding:8px 20px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:14px;font-weight:500">✗ Deny</button>
<button id="approval-approve" style="padding:8px 20px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:14px;font-weight:500">✓ Approve</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
async function resolveApproval(approved) {
const always = document.getElementById('approval-always').checked;
const endpoint = approved ? `/api/approvals/${approval.id}/approve` : `/api/approvals/${approval.id}/deny`;
try {
await fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({always, reason: approved ? 'Approved via UI' : 'Denied via UI'})
});
} catch (e) {
console.error('Failed to resolve approval:', e);
}
overlay.style.animation = 'fadeOut 0.2s';
setTimeout(() => {
overlay.remove();
approvalModalShown = false;
if (pendingApprovals.length > 0) {
showApprovalModal(pendingApprovals.shift());
}
}, 200);
}
document.getElementById('approval-approve').addEventListener('click', () => resolveApproval(true));
document.getElementById('approval-deny').addEventListener('click', () => resolveApproval(false));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
resolveApproval(false);
}
});
}
function initApprovalStream() {
if (approvalSSESource) return;
try {
approvalSSESource = new EventSource('/api/approvals/stream');
approvalSSESource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.event === 'new' && data.data) {
showApprovalModal(data.data);
}
} catch (e) {
console.error('Failed to parse approval SSE:', e);
}
};
approvalSSESource.onerror = () => {
console.warn('Approval SSE connection error, retrying...');
if (approvalSSESource) {
approvalSSESource.close();
approvalSSESource = null;
}
setTimeout(initApprovalStream, 5000);
};
} catch (e) {
console.error('Failed to init approval stream:', e);
}
}
// ── Extensible Component Registry ────────────────────────────────────
// Maps component type names to custom render functions.
// Custom renderers take priority over the built-in switch in renderComponent().
@@ -625,6 +742,11 @@ const DASHBOARD_STYLE = `
.db-popover { position: relative; display: inline-block; }
.db-popover-content { display: none; position: absolute; top: calc(100% + 8px); left: 0; min-width: 200px; padding: 16px; background: var(--db-sidebar-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); z-index: 100; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
.db-popover.open .db-popover-content { display: block; }
/* ── Approval Modal Animations ──────────────────────── */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
`;
let activePageId = null;
@@ -803,6 +925,16 @@ async function init() {
}
await buildDashboard();
// Initialize approval stream for modal notifications
initApprovalStream();
// Listen for approval events from chat stream
window.addEventListener('aiui:approval-required', (e) => {
if (e.detail) {
showApprovalModal(e.detail);
}
});
}
async function buildDashboard() {