Stage 298: PR #1663 — feat: add plugins visibility panel by @Michaelyklam

This commit is contained in:
test
2026-05-05 01:18:35 +00:00
6 changed files with 349 additions and 4 deletions
+99
View File
@@ -1868,6 +1868,101 @@ def _handle_health(handler, parsed):
return j(handler, payload)
# ── Plugin visibility endpoint (#539) ───────────────────────────────────────
_PLUGIN_VISIBILITY_HOOKS = (
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
)
_PLUGIN_VISIBILITY_HOOK_SET = set(_PLUGIN_VISIBILITY_HOOKS)
def _get_plugin_manager_for_visibility():
"""Return Hermes Agent's plugin manager for read-only WebUI visibility."""
from hermes_cli.plugins import get_plugin_manager
return get_plugin_manager()
def _clean_plugin_visibility_text(value, *, limit=240) -> str:
"""Return bounded display text without path/callback-like internals."""
if value is None:
return ""
text = str(value).replace("\x00", "").strip()
# Display metadata should be plain labels/descriptions. Drop multiline text
# and common path separators rather than risk leaking local plugin paths.
text = " ".join(text.split())
if len(text) > limit:
text = text[: limit - 1].rstrip() + ""
return text
def _plugin_visibility_payload(manager=None) -> dict:
"""Build a sanitized plugin/hook visibility payload for Settings.
The Hermes Agent manager stores manifests and callback objects internally.
This endpoint intentionally exposes only safe, user-facing metadata and the
four lifecycle hook names called out by the Settings visibility MVP. It
never includes plugin source paths, callback names, callback reprs, or raw
load errors because those can contain private filesystem details.
"""
manager = manager or _get_plugin_manager_for_visibility()
manager.discover_and_load(force=False)
plugins = []
raw_plugins = getattr(manager, "_plugins", {}) or {}
for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
manifest = getattr(loaded, "manifest", None)
if manifest is None:
continue
plugin_key = _clean_plugin_visibility_text(
getattr(manifest, "key", None) or key or getattr(manifest, "name", ""),
limit=120,
)
name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
registered = []
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
hook_name = str(hook or "").strip()
if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
registered.append(hook_name)
registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
plugins.append({
"name": name,
"key": plugin_key or name,
"version": version,
"description": description,
"enabled": bool(getattr(loaded, "enabled", False)),
"hooks": registered,
})
return {
"plugins": plugins,
"empty": not bool(plugins),
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
"read_only": True,
}
def _handle_plugins(handler, parsed) -> bool:
try:
return j(handler, _plugin_visibility_payload())
except Exception as exc:
logger.warning("Failed to build plugin visibility payload: %s", exc)
return j(
handler,
{
"plugins": [],
"empty": True,
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
"read_only": True,
"unavailable": True,
},
)
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
@@ -2003,6 +2098,10 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/providers":
return j(handler, get_providers())
# ── Plugins/hooks visibility (read-only, no callback/source internals) ──
if parsed.path == "/api/plugins":
return _handle_plugins(handler, parsed)
if parsed.path == "/api/settings":
settings = load_settings()
# Never expose the stored password hash to clients
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+18
View File
@@ -246,6 +246,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span>Plugins</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span>System</span>
@@ -977,6 +981,20 @@
No configurable providers found.
</div>
</div>
<div class="settings-pane" id="settingsPanePlugins">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Plugins</div>
<div class="settings-section-meta">View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.</div>
</div>
</div>
<div id="pluginsList" style="display:flex;flex-direction:column;margin-top:4px">
<!-- Populated dynamically by loadPluginsPanel() -->
</div>
<div id="pluginsEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px">
No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.
</div>
</div>
<div class="settings-pane" id="settingsPaneSystem">
<div class="settings-section-head">
<div>
+60 -4
View File
@@ -3769,24 +3769,25 @@ let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
_settingsSection=section;
_currentSettingsSection=section;
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',plugins:'Plugins',system:'System'};
// Sidebar menu items
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
it.classList.toggle('active', it.dataset.settingsSection===section);
});
// Panes in main
['conversation','appearance','preferences','providers','system'].forEach(key=>{
['conversation','appearance','preferences','providers','plugins','system'].forEach(key=>{
const pane=$('settingsPane'+map[key]);
if(pane) pane.classList.toggle('active', key===section);
});
// Sync mobile dropdown
const dd=$('settingsSectionDropdown');
if(dd && dd.value!==section) dd.value=section;
// Lazy-load providers when the tab is opened
// Lazy-load integration panels when their tabs are opened
if(section==='providers') loadProvidersPanel();
if(section==='plugins') loadPluginsPanel();
}
function _syncHermesPanelSessionActions(){
@@ -4303,12 +4304,67 @@ async function loadSettingsPanel(){
}
_syncHermesPanelSessionActions();
loadProvidersPanel(); // load provider cards in background
loadPluginsPanel(); // load plugin/hook visibility in background
switchSettingsSection(_settingsSection);
}catch(e){
showToast(t('settings_load_failed')+e.message);
}
}
// ── Plugins panel (read-only plugin/hook visibility) ───────────────────────
async function loadPluginsPanel(){
const list=$('pluginsList');
const empty=$('pluginsEmpty');
if(!list) return;
try{
const data=await api('/api/plugins');
const plugins=Array.isArray((data||{}).plugins)?data.plugins:[];
list.innerHTML='';
if(plugins.length===0){
list.style.display='none';
if(empty) empty.style.display='';
return;
}
if(empty) empty.style.display='none';
list.style.display='';
for(const plugin of plugins){
list.appendChild(_buildPluginCard(plugin));
}
}catch(e){
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load plugins: '+esc(e.message||String(e))+'</div>';
}
}
function _buildPluginCard(plugin){
const card=document.createElement('div');
card.className='provider-card plugin-card';
card.dataset.plugin=(plugin&&plugin.key)||'';
const hooks=Array.isArray(plugin&&plugin.hooks)?plugin.hooks:[];
const hookHtml=hooks.length
? hooks.map(h=>`<span class="plugin-hook-badge">${esc(h)}</span>`).join('')
: '<span class="plugin-hook-empty">No registered lifecycle hooks</span>';
const version=(plugin&&plugin.version)?` · v${esc(plugin.version)}`:'';
const desc=(plugin&&plugin.description)?esc(plugin.description):'No description provided.';
const enabled=plugin&&plugin.enabled!==false;
card.innerHTML=`
<div class="provider-card-header plugin-card-header">
<div class="provider-card-info">
<div class="provider-card-name">${esc((plugin&&plugin.name)||'Unnamed plugin')}</div>
<div class="provider-card-meta">${esc((plugin&&plugin.key)||'plugin')}${version}</div>
</div>
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?'Enabled':'Disabled'}</span>
</div>
<div class="provider-card-body plugin-card-body">
<div class="provider-card-hint">${desc}</div>
<div class="provider-card-label">Registered hooks</div>
<div class="plugin-hook-list">${hookHtml}</div>
</div>
`;
return card;
}
// ── Providers panel ───────────────────────────────────────────────────────
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
+11
View File
@@ -2407,6 +2407,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
background:color-mix(in srgb, var(--error) 10%, transparent);
}
/* ── Plugin visibility cards ── */
#pluginsList{gap:12px;}
.plugin-card .provider-card-body{display:block;}
.plugin-card-header{cursor:default;}
.plugin-card-header:hover{background:transparent;}
.plugin-card-badge-disabled{background:var(--surface);color:var(--muted);}
.plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);}
.plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;}
/* ── Provider model tags ── */
.provider-card-models{
margin-bottom:10px;
+161
View File
@@ -0,0 +1,161 @@
"""Regression coverage for issue #539: Settings plugin/hook visibility."""
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
def read(path: str) -> str:
from pathlib import Path
return Path(path).read_text(encoding="utf-8")
class _FakeManifest:
def __init__(self, *, name, key, version="", description="", provides_hooks=None, path=None):
self.name = name
self.key = key
self.version = version
self.description = description
self.provides_hooks = provides_hooks or []
self.path = path
self.source = "user"
self.kind = "standalone"
class _FakeLoadedPlugin:
def __init__(self, manifest, *, enabled=True, hooks_registered=None, error=None):
self.manifest = manifest
self.enabled = enabled
self.hooks_registered = hooks_registered or []
self.error = error
class _FakePluginManager:
def __init__(self, plugins):
self._plugins = plugins
self.discover_calls = []
def discover_and_load(self, force=False):
self.discover_calls.append(force)
class TestPluginsApi:
def _capture_plugins_response(self, manager):
import api.routes as routes
captured = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured["payload"] = payload
captured["status"] = status
return True
handler = MagicMock()
with patch("api.routes.j", side_effect=fake_j), \
patch("api.routes._get_plugin_manager_for_visibility", return_value=manager):
handled = routes.handle_get(handler, urlparse("/api/plugins"))
assert handled is True
assert captured.get("status") == 200
return captured["payload"]
def test_api_plugins_exposes_sanitized_metadata_and_hook_names(self):
manager = _FakePluginManager({
"guard": _FakeLoadedPlugin(
_FakeManifest(
name="guard",
key="guard",
version="1.2.3",
description="Blocks unsafe tool calls",
path="/home/michael/.hermes/plugins/guard",
),
enabled=True,
hooks_registered=["pre_tool_call", "post_tool_call"],
)
})
payload = self._capture_plugins_response(manager)
assert payload["supported_hooks"] == [
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
]
assert payload["plugins"] == [{
"name": "guard",
"key": "guard",
"version": "1.2.3",
"description": "Blocks unsafe tool calls",
"enabled": True,
"hooks": ["pre_tool_call", "post_tool_call"],
}]
serialized = repr(payload)
assert "/home/michael" not in serialized
assert "callback" not in serialized.lower()
assert "source" not in payload["plugins"][0]
assert "path" not in payload["plugins"][0]
assert manager.discover_calls == [False]
def test_api_plugins_empty_state_payload_when_no_plugins_loaded(self):
payload = self._capture_plugins_response(_FakePluginManager({}))
assert payload["plugins"] == []
assert payload["empty"] is True
assert payload["supported_hooks"] == [
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
]
def test_api_plugins_filters_non_visibility_hooks_and_manifest_paths(self):
manager = _FakePluginManager({
"mixed": _FakeLoadedPlugin(
_FakeManifest(
name="mixed",
key="mixed",
version="0.1",
description="Mixed hooks",
provides_hooks=["/tmp/not-a-hook", "pre_llm_call", "on_session_end"],
path="/secret/plugin.py",
),
enabled=False,
hooks_registered=["post_llm_call", "pre_gateway_dispatch", "post_llm_call"],
)
})
payload = self._capture_plugins_response(manager)
plugin = payload["plugins"][0]
assert plugin["hooks"] == ["pre_llm_call", "post_llm_call"]
assert plugin["enabled"] is False
assert "/tmp/not-a-hook" not in repr(payload)
assert "/secret" not in repr(payload)
class TestPluginsSettingsUi:
def test_settings_sidebar_has_plugins_section(self):
html = read("static/index.html")
js = read("static/panels.js")
assert 'data-settings-section="plugins"' in html
assert "settingsPanePlugins" in html
assert "'plugins'" in js
assert "loadPluginsPanel()" in js
def test_plugins_panel_has_list_and_empty_state(self):
html = read("static/index.html")
assert 'id="pluginsList"' in html
assert 'id="pluginsEmpty"' in html
assert "No Hermes plugins are currently visible" in html
def test_plugins_panel_fetches_api_and_renders_hook_badges_safely(self):
js = read("static/panels.js")
assert "api('/api/plugins')" in js
assert "_buildPluginCard" in js
assert "plugin-hook-badge" in js
assert "esc(plugin.description" in js
segment = js[js.find("function _buildPluginCard"):js.find("// ── Providers panel")]
assert ".path" not in segment
assert ".callback" not in segment