Merge pull request #5098 from franksong2702/franksong2702/extensions-gallery-source-permissions

Improve extension gallery source and permissions display
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-27 22:11:33 -07:00
commit 63d818f8a4
5 files changed
+176 -4

No files matched your search

+2
View File
@@ -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)
- **Opt-in CSP `frame-src` allowlist so an extension can embed a self-hosted web app in an iframe.** A new `HERMES_WEBUI_CSP_FRAME_EXTRA` env var widens what the WebUI page may embed in an `<iframe>` (default is same-origin only), mirroring the existing `HERMES_WEBUI_CSP_CONNECT_EXTRA` knob — space-separated `http(s)` origins with optional wildcard subdomain and port, validated and ignored if malformed. This unblocks an "external app tab" extension (pin Grafana / Vaultwarden / your own dashboard as a tab). It only governs what *this* page can embed; `frame-ancestors` stays `'none'`, so it does not let anyone embed the WebUI itself. Default-off; with the var unset, behavior is unchanged. Documented in `docs/EXTENSIONS.md`.
- **`/moa` now works in the WebUI, routing the turn through your configured Mixture-of-Agents preset.** Typing `/moa <prompt>` 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)
+14
View File
@@ -681,6 +681,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'No extra permissions declared.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -2316,6 +2317,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Nessuna autorizzazione extra dichiarata.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -3942,6 +3944,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: '追加の権限は宣言されていません。',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -6276,6 +6279,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Дополнительные разрешения не указаны.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -7827,6 +7831,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'No se declararon permisos adicionales.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -9062,6 +9067,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Keine zusätzlichen Berechtigungen deklariert.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -10964,6 +10970,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: '未声明额外权限。',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -11821,6 +11828,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: '未宣告額外權限。',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -13343,6 +13351,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Nenhuma permissão extra declarada.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -14871,6 +14880,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: '추가 권한이 선언되지 않았습니다.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -16509,6 +16519,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Aucune permission supplémentaire déclarée.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -18103,6 +18114,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Ek izin bildirilmedi.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -19743,6 +19755,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Nie zadeklarowano dodatkowych uprawnień.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
@@ -21271,6 +21284,7 @@ const LOCALES = {
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
ext_gallery_permissions_empty: 'Không khai báo quyền bổ sung nào.',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
+120 -2
View File
@@ -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 `<a class="extension-gallery-source-link" href="${esc(url)}" target="_blank" rel="noopener noreferrer">Source</a>`;
}
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
? '<div class="extension-gallery-permission-list">'+rows.map(([label,value])=>`
<div class="extension-gallery-permission-row">
<span class="extension-gallery-permission-label">${esc(label)}</span>
<span class="extension-gallery-permission-value">${esc(value)}</span>
</div>`).join('')+'</div>'
: `<div class="extension-gallery-permission-empty">${esc(t('ext_gallery_permissions_empty'))}</div>`;
return `<details class="extension-gallery-perms">
<summary>${esc(t('ext_gallery_permissions_show'))}</summary>
${body}
</details>`;
}
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=>`<span class="extension-gallery-badge">${esc(String(c))}</span>`).join('');
const permsHtml=perms?`<details class="extension-gallery-perms"><summary data-i18n="ext_gallery_permissions_show">Permissions</summary><pre>${esc(JSON.stringify(perms,null,2))}</pre></details>`:'';
const metaBits=[];
if(author) metaBits.push('by '+author);
if(version) metaBits.push('v'+version);
const sourceLinkHtml=_extensionSourceLink(entry);
const metaHtml=(metaBits.length||sourceLinkHtml)
? `<div class="extension-gallery-meta">${metaBits.length?`<span>${metaBits.join(' · ')}</span>`:''}${sourceLinkHtml}</div>`
: '';
const permsHtml=perms?_extensionPermissionSummary(perms):'';
const postInstallHtml=_extensionPostInstallNote(entry,isInstalled);
const actionBtn=isInstalled
?`<button class="extension-gallery-uninstall-btn" data-ext-uninstall-id="${id}" type="button" data-i18n="ext_gallery_uninstall">Uninstall</button>`
@@ -8602,7 +8720,7 @@ function _renderExtensionsGallery(entries,statusData){
<div class="extension-gallery-head">
<div class="extension-gallery-info">
<div class="extension-gallery-name">${name}${installedBadge}</div>
<div class="extension-gallery-meta">${author?'by '+author:''}${version?' · v'+version:''}</div>
${metaHtml}
</div>
</div>
<div class="extension-gallery-desc">${desc}</div>
+9 -2
View File
@@ -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 ── */
+31
View File
@@ -247,6 +247,33 @@ 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 "t('ext_gallery_permissions_empty')" 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 "<pre>" not in render_block
def test_copy_extensions_diagnostics_copies_current_sanitized_payload():
copy_block = _between("function copyExtensionsDiagnostics", "// ── Plugins panel")
@@ -268,8 +295,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():
@@ -280,6 +310,7 @@ def test_extensions_i18n_keys_exist_for_all_locales():
"settings_tab_extensions",
"ext_gallery_next_step",
"ext_gallery_after_install",
"ext_gallery_permissions_empty",
"ext_gallery_local_component_required",
"ext_gallery_local_app_label",
"ext_gallery_required_suffix",