From 06465bbd913d5ac14714e6540eed4c3b6e876223 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 28 Jun 2026 12:44:57 +0800 Subject: [PATCH] Improve extension gallery source and permissions display --- CHANGELOG.md | 2 + static/panels.js | 122 +++++++++++++++++++++++- static/style.css | 11 ++- tests/test_extensions_settings_panel.py | 29 ++++++ 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad7f49cd..298a64bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +- **Settings → Extensions gallery cards now link back to their source and show permissions in readable groups.** Gallery and Installed cards now surface a safe "Source" link from registry metadata (falling back to the extension registry path when needed), and the permissions disclosure renders WebUI API access, storage, DOM, sidecar/native-host, filesystem, and external-network declarations as plain rows instead of raw JSON. Thanks @franksong2702. (#5093, #5095) + - **`/moa` now works in the WebUI, routing the turn through your configured Mixture-of-Agents preset.** Typing `/moa ` runs that turn through the agent's MoA config (the same preset/usage you'd get from the CLI) instead of a plain single-model turn; `/moa` with no prompt shows the available usage. The MoA config is resolved **server-side** from your profile config — the browser only signals that an override is wanted, it can't supply the config — and it's applied per-turn (not persisted). It fails closed on gateway-backed sessions. Thanks @rodboev. (#5070, #5057) - **Three new opt-in appearance skins: GitHub, Codex, and Terracotta.** All are CSS-only, namespaced under `[data-skin]`, and selectable from Settings → Appearance — they change nothing unless you pick them. GitHub uses a restrained graphite + Primer-blue palette; Codex a minimal editor look with a muted sage accent; Terracotta a warm clay accent on a soft neutral background. Thanks @gottipx (GitHub #4634, Codex #4636, Terracotta #4635 — renamed from the originally-proposed name to a descriptive material name). diff --git a/static/panels.js b/static/panels.js index 182dfccb..42e0f694 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8512,6 +8512,117 @@ function _extensionSafeHttpUrl(value){ } } +function _extensionRegistrySourceUrl(entryPath){ + const raw=String(entryPath||'').trim(); + if(!raw||raw.startsWith('/')||raw.includes('\\')||raw.includes('\0')) return ''; + const parts=raw.split('/').filter(Boolean); + if(parts.length===0||parts.some(part=>part==='.'||part==='..')) return ''; + const folder=parts.length>1?parts.slice(0,-1):parts; + return 'https://github.com/hermes-webui/hermes-webui-extensions/tree/main/'+folder.map(encodeURIComponent).join('/'); +} + +function _extensionSourceUrl(entry){ + if(!entry||typeof entry!=='object') return ''; + const candidates=[ + entry.homepage, + entry.repository_url, + entry.repo_url, + entry.source_url, + entry.source, + ]; + const repository=entry.repository; + if(typeof repository==='string'){ + candidates.push(repository); + }else if(repository&&typeof repository==='object'){ + candidates.push(repository.url,repository.html_url); + } + for(const candidate of candidates){ + const safe=_extensionSafeHttpUrl(candidate); + if(safe) return safe; + } + return _extensionSafeHttpUrl(_extensionRegistrySourceUrl(entry.entry_path||entry.runtime_manifest_path)); +} + +function _extensionSourceLink(entry){ + const url=_extensionSourceUrl(entry); + if(!url) return ''; + return `Source`; +} + +function _extensionPermissionList(value){ + if(!Array.isArray(value)) return ''; + const items=value + .map(item=>String(item||'').trim()) + .filter(Boolean); + return items.length?items.join(', '):''; +} + +function _extensionPermissionRows(perms){ + if(!perms||typeof perms!=='object') return []; + const rows=[]; + const api=(perms.webui_api&&typeof perms.webui_api==='object')?perms.webui_api:{}; + const apiRead=_extensionPermissionList(api.read); + const apiWrite=_extensionPermissionList(api.write); + if(apiRead) rows.push(['WebUI API reads',apiRead]); + if(apiWrite) rows.push(['WebUI API writes',apiWrite]); + if(perms.webui_navigation===true) rows.push(['Navigation','Can open or switch WebUI views']); + + const sidecarCommands=(perms.sidecar_commands&&typeof perms.sidecar_commands==='object')?perms.sidecar_commands:{}; + const commandLabels=[ + ['from_loopback','accepts loopback commands'], + ['can_switch_sessions','switch sessions'], + ['can_write_drafts','write drafts'], + ['can_autosend','auto-send drafts'], + ['can_respond_approval','respond to approvals'], + ['can_respond_clarify','respond to clarifications'], + ]; + const commands=commandLabels + .filter(([key])=>sidecarCommands[key]===true) + .map(([,label])=>label); + if(commands.length) rows.push(['Sidecar commands',commands.join(', ')]); + + const dom=(perms.dom&&typeof perms.dom==='object')?perms.dom:{}; + const domItems=[]; + if(dom.owned===true) domItems.push('renders extension-owned UI'); + if(dom.mutates_core_views===true) domItems.push('can alter core WebUI views'); + if(domItems.length) rows.push(['DOM access',domItems.join(', ')]); + + const storage=(perms.storage&&typeof perms.storage==='object')?perms.storage:{}; + const ownedStorage=_extensionPermissionList(storage.owned||storage.owned_keys); + const sharedStorage=_extensionPermissionList(storage.shared_webui_keys); + if(ownedStorage) rows.push(['Owned storage keys',ownedStorage]); + if(sharedStorage) rows.push(['Shared WebUI storage',sharedStorage]); + + if(perms.loopback_sidecar===true) rows.push(['Loopback sidecar','Can contact a declared local loopback helper']); + if(perms.native_host===true) rows.push(['Native host','Requires a local native host or desktop app']); + + const filesystem=(perms.filesystem&&typeof perms.filesystem==='object')?perms.filesystem:{}; + if(filesystem.arbitrary===true){ + rows.push(['Filesystem','Can access arbitrary filesystem paths']); + }else if(filesystem.serves_bundled_assets===true){ + rows.push(['Filesystem','Serves bundled extension assets only']); + } + if(perms.network_external===true||perms.external_network===true){ + rows.push(['External network','Can contact external network origins']); + } + return rows; +} + +function _extensionPermissionSummary(perms){ + const rows=_extensionPermissionRows(perms); + const body=rows.length + ? '' + : ''; + return ``; +} + function _extensionPostInstallNote(entry,isInstalled){ const lifecycle=(entry&&entry.lifecycle&&typeof entry.lifecycle==='object')?entry.lifecycle:{}; const post=(entry&&entry.post_install&&typeof entry.post_install==='object')?entry.post_install:null; @@ -8592,7 +8703,14 @@ function _renderExtensionsGallery(entries,statusData){ const isInstalled=installedIds.has(String(entry.id||'')); const restartRequired=!!(entry.lifecycle&&(entry.lifecycle.restart_required||entry.lifecycle.webui_restart_required)); const badgesHtml=caps.map(c=>`${esc(String(c))}`).join(''); - const permsHtml=perms?``:''; + const metaBits=[]; + if(author) metaBits.push('by '+author); + if(version) metaBits.push('v'+version); + const sourceLinkHtml=_extensionSourceLink(entry); + const metaHtml=(metaBits.length||sourceLinkHtml) + ? `` + : ''; + const permsHtml=perms?_extensionPermissionSummary(perms):''; const postInstallHtml=_extensionPostInstallNote(entry,isInstalled); const actionBtn=isInstalled ?`` @@ -8602,7 +8720,7 @@ function _renderExtensionsGallery(entries,statusData){ diff --git a/static/style.css b/static/style.css index dfd2bd0e..83c749cc 100644 --- a/static/style.css +++ b/static/style.css @@ -5144,7 +5144,9 @@ main.main > #mainPlugin{display:none;} .extension-gallery-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;} .extension-gallery-info{flex:1;min-width:0;} .extension-gallery-name{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px;flex-wrap:wrap;} -.extension-gallery-meta{font-size:11px;color:var(--muted);margin-top:2px;} +.extension-gallery-meta{font-size:11px;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;} +.extension-gallery-source-link{display:inline-flex;align-items:center;color:var(--accent);font-weight:600;text-decoration:none;} +.extension-gallery-source-link:hover{text-decoration:underline;} .extension-gallery-desc{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.5;} .extension-gallery-badge-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;} .extension-gallery-badge{display:inline-flex;align-items:center;background:var(--code-bg);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:600;font-family:var(--font-mono);} @@ -5157,7 +5159,11 @@ main.main > #mainPlugin{display:none;} .extension-gallery-next-link:hover{text-decoration:underline;} .extension-gallery-perms{margin-top:8px;font-size:12px;} .extension-gallery-perms summary{cursor:pointer;color:var(--muted);user-select:none;} -.extension-gallery-perms pre{margin:6px 0 0;padding:8px;background:var(--code-bg);border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;} +.extension-gallery-permission-list{display:flex;flex-direction:column;gap:6px;margin-top:7px;padding:8px 9px;border:1px solid var(--border);border-radius:8px;background:var(--subtle-bg,rgba(127,127,127,.06));} +.extension-gallery-permission-row{display:grid;grid-template-columns:minmax(110px,.45fr) 1fr;gap:8px;align-items:start;min-width:0;} +.extension-gallery-permission-label{font-size:10.5px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:0;} +.extension-gallery-permission-value{font-size:12px;color:var(--text);line-height:1.35;overflow-wrap:anywhere;} +.extension-gallery-permission-empty{margin-top:7px;color:var(--muted);font-size:12px;} .extension-gallery-actions{display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap;} .extension-gallery-install-btn,.extension-gallery-uninstall-btn{padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid transparent;transition:background .15s,color .15s;} .extension-gallery-install-btn{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent);} @@ -5176,6 +5182,7 @@ main.main > #mainPlugin{display:none;} .extension-sidecar-row-head{align-items:flex-start;flex-direction:column;} .extension-sidecar-fields>div{flex-direction:column;gap:2px;} .extension-sidecar-fields span{flex:0 0 auto;} + .extension-gallery-permission-row{grid-template-columns:1fr;gap:2px;} } /* ── Provider model tags ── */ diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index 20065e1a..ecc67b91 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -247,6 +247,32 @@ def test_extensions_gallery_renders_post_install_guidance(): assert "webui_restart_required" in install_block +def test_extensions_gallery_links_sources_and_humanizes_permissions(): + helper_block = _between("function _extensionRegistrySourceUrl", "function _extensionPostInstallNote") + render_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons") + + assert "entry.homepage" in helper_block + assert "entry.repository_url" in helper_block + assert "entry.entry_path||entry.runtime_manifest_path" in helper_block + assert "hermes-webui/hermes-webui-extensions/tree/main" in helper_block + assert "encodeURIComponent" in helper_block + assert "extension-gallery-source-link" in helper_block + assert "target=\"_blank\"" in helper_block + assert "rel=\"noopener noreferrer\"" in helper_block + assert "webui_api" in helper_block + assert "sidecar_commands" in helper_block + assert "dom.mutates_core_views" in helper_block + assert "storage.shared_webui_keys" in helper_block + assert "loopback_sidecar" in helper_block + assert "native_host" in helper_block + assert "network_external" in helper_block + assert "extension-gallery-permission-row" in helper_block + assert "_extensionSourceLink(entry)" in render_block + assert "_extensionPermissionSummary(perms)" in render_block + assert "JSON.stringify(perms" not in render_block + assert "
" not in render_block
+
+
 def test_copy_extensions_diagnostics_copies_current_sanitized_payload():
     copy_block = _between("function copyExtensionsDiagnostics", "// ── Plugins panel")
 
@@ -268,8 +294,11 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
     assert ".extension-sidecar-list" in STYLE_CSS
     assert ".extension-sidecar-runtime" in STYLE_CSS
     assert ".extension-sidecar-status-badge" in STYLE_CSS
+    assert ".extension-gallery-source-link" in STYLE_CSS
     assert ".extension-gallery-next-step" in STYLE_CSS
     assert ".extension-gallery-next-link" in STYLE_CSS
+    assert ".extension-gallery-permission-list" in STYLE_CSS
+    assert ".extension-gallery-permission-row" in STYLE_CSS
 
 
 def test_extensions_i18n_keys_exist_for_all_locales():