feat: add plugins visibility panel
This commit is contained in:
parent
2bbaad3135
commit
60ed948f42
6 changed files with 349 additions and 4 deletions
|
|
@ -1790,6 +1790,101 @@ def _handle_health(handler, parsed):
|
||||||
return j(handler, payload)
|
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:
|
def handle_get(handler, parsed) -> bool:
|
||||||
"""Handle all GET routes. Returns True if handled, False for 404."""
|
"""Handle all GET routes. Returns True if handled, False for 404."""
|
||||||
|
|
||||||
|
|
@ -1925,6 +2020,10 @@ def handle_get(handler, parsed) -> bool:
|
||||||
if parsed.path == "/api/providers":
|
if parsed.path == "/api/providers":
|
||||||
return j(handler, get_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":
|
if parsed.path == "/api/settings":
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
# Never expose the stored password hash to clients
|
# Never expose the stored password hash to clients
|
||||||
|
|
|
||||||
BIN
docs/pr-media/539/plugins-panel.png
Normal file
BIN
docs/pr-media/539/plugins-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
|
|
@ -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>
|
<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>
|
<span data-i18n="providers_tab_title">Providers</span>
|
||||||
</button>
|
</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')">
|
<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>
|
<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>
|
<span>System</span>
|
||||||
|
|
@ -977,6 +981,20 @@
|
||||||
No configurable providers found.
|
No configurable providers found.
|
||||||
</div>
|
</div>
|
||||||
</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-pane" id="settingsPaneSystem">
|
||||||
<div class="settings-section-head">
|
<div class="settings-section-head">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -3739,24 +3739,25 @@ let _settingsPreferencesAutosaveTimer = null;
|
||||||
let _settingsPreferencesAutosaveRetryPayload = null;
|
let _settingsPreferencesAutosaveRetryPayload = null;
|
||||||
|
|
||||||
function switchSettingsSection(name){
|
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;
|
_settingsSection=section;
|
||||||
_currentSettingsSection=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
|
// Sidebar menu items
|
||||||
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
|
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
|
||||||
it.classList.toggle('active', it.dataset.settingsSection===section);
|
it.classList.toggle('active', it.dataset.settingsSection===section);
|
||||||
});
|
});
|
||||||
// Panes in main
|
// Panes in main
|
||||||
['conversation','appearance','preferences','providers','system'].forEach(key=>{
|
['conversation','appearance','preferences','providers','plugins','system'].forEach(key=>{
|
||||||
const pane=$('settingsPane'+map[key]);
|
const pane=$('settingsPane'+map[key]);
|
||||||
if(pane) pane.classList.toggle('active', key===section);
|
if(pane) pane.classList.toggle('active', key===section);
|
||||||
});
|
});
|
||||||
// Sync mobile dropdown
|
// Sync mobile dropdown
|
||||||
const dd=$('settingsSectionDropdown');
|
const dd=$('settingsSectionDropdown');
|
||||||
if(dd && dd.value!==section) dd.value=section;
|
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==='providers') loadProvidersPanel();
|
||||||
|
if(section==='plugins') loadPluginsPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _syncHermesPanelSessionActions(){
|
function _syncHermesPanelSessionActions(){
|
||||||
|
|
@ -4273,12 +4274,67 @@ async function loadSettingsPanel(){
|
||||||
}
|
}
|
||||||
_syncHermesPanelSessionActions();
|
_syncHermesPanelSessionActions();
|
||||||
loadProvidersPanel(); // load provider cards in background
|
loadProvidersPanel(); // load provider cards in background
|
||||||
|
loadPluginsPanel(); // load plugin/hook visibility in background
|
||||||
switchSettingsSection(_settingsSection);
|
switchSettingsSection(_settingsSection);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
showToast(t('settings_load_failed')+e.message);
|
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 ───────────────────────────────────────────────────────
|
// ── Providers panel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
||||||
|
|
|
||||||
|
|
@ -2407,6 +2407,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||||
background:color-mix(in srgb, var(--error) 10%, transparent);
|
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 model tags ── */
|
||||||
.provider-card-models{
|
.provider-card-models{
|
||||||
margin-bottom:10px;
|
margin-bottom:10px;
|
||||||
|
|
|
||||||
161
tests/test_plugins_panel.py
Normal file
161
tests/test_plugins_panel.py
Normal 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
|
||||||
Loading…
Reference in a new issue