From a76c794d6deafdc8fcb83a1706c4ef5c80bf2620 Mon Sep 17 00:00:00 2001
From: Rod Boev
" - block = "\n".join(script_tags) + "\n" + block = runtime_tag + "\n" + "\n".join(script_tags) + "\n" if body_marker in result: result = result.replace(body_marker, block + body_marker, 1) else: diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 787f622fb..55543f39c 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -165,6 +165,65 @@ Loopback sidecars do **not** change asset injection behavior. They are only reported by diagnostics so an operator can see that a local companion service was declared and optionally check its health from the browser. +Extension entries may declare browser-local settings when they also request +extension-owned storage: + +```json +{ + "id": "desktop-companion", + "permissions": { + "storage": { + "owned": true + } + }, + "settings_schema": [ + { + "key": "show_badge", + "type": "boolean", + "label": "Show badge", + "default": true + }, + { + "key": "mode", + "type": "enum", + "label": "Mode", + "options": [ + {"value": "compact", "label": "Compact"}, + {"value": "full", "label": "Full"} + ], + "default": "compact" + } + ] +} +``` + +Settings are a first-pass browser feature. WebUI sanitizes the manifest schema, injects the accepted schema before extension scripts, and leaves persistence to the browser. The backend does not store extension settings or expose a generic settings write route, and it does not treat these values as secrets. + +The sanitizer accepts only `boolean`, `string`, `number`, `integer`, and `enum` +fields. It drops `sensitive: true` fields, unsupported types, malformed enum +options, duplicate keys after the first valid field, and defaults that do not +match the declared type. `settings_schema` is honored only when +`permissions.storage.owned` is exactly `true`. + +Extension scripts can use the sanctioned browser accessors: + +```js +const settings = window.HermesExtensionSettings.settingsForExtension("desktop-companion"); +const value = settings.get("show_badge"); +settings.set("show_badge", false); + +const storage = window.HermesExtensionSettings.storageForExtension("desktop-companion"); +storage.set("lastPanel", "settings"); + +const sameSettings = window.hermesExt.settings.forExtension("desktop-companion"); +const sameStorage = window.hermesExt.storage.forExtension("desktop-companion"); +``` + +Settings persist only non-default overrides. Resetting settings removes those +overrides and returns schema defaults. Extension-owned storage uses a separate +browser-local namespace, and clearing storage removes that namespace without +changing settings. + ## URL rules Injected asset URLs are deliberately restricted: @@ -552,3 +611,8 @@ allowlisted scalar fields such as `sidecar`, `native_host`, `bridge`, `last_seen_at`, and `webui_origin`. This keeps sidecar-specific diagnostics machine-readable without making WebUI depend on any one extension's private payload shape. + +When sanitized settings are present, Settings -> Extensions renders +browser-local controls for installed manifest entries. Save, reset, and clear +storage actions call `window.HermesExtensionSettings`; they do not call backend +storage routes and do not write WebUI settings. diff --git a/static/extension_settings.js b/static/extension_settings.js new file mode 100644 index 000000000..2315d2744 --- /dev/null +++ b/static/extension_settings.js @@ -0,0 +1,264 @@ +(function(){ + 'use strict'; + + const SETTINGS_PREFIX='hermes.ext.settings.'; + const STORAGE_PREFIX='hermes.ext.storage.'; + const FIELD_TYPES=new Set(['boolean','string','number','integer','enum']); + const schemas=new Map(); + + function extensionId(value){ + return String(value||'').trim(); + } + + function namespaceForExtension(id){ + const clean=extensionId(id); + if(!clean) throw new Error('extensionId is required'); + return encodeURIComponent(clean); + } + + function settingsKey(id){ + return SETTINGS_PREFIX+namespaceForExtension(id); + } + + function storageKey(id){ + return STORAGE_PREFIX+namespaceForExtension(id); + } + + function text(value,fallback){ + const raw=typeof value==='string'?value.trim():''; + return raw||fallback||''; + } + + function enumOptions(options){ + if(!Array.isArray(options)||!options.length) return null; + const out=[]; + const seen=new Set(); + for(const option of options){ + let value=''; + let label=''; + if(typeof option==='string'){ + value=option.trim(); + label=value; + }else if(option&&typeof option==='object'&&typeof option.value==='string'){ + value=option.value.trim(); + label=text(option.label,value); + }else{ + return null; + } + if(!value||seen.has(value)) return null; + seen.add(value); + out.push({value,label}); + } + return out; + } + + function defaultFor(type,rawDefault,options){ + if(type==='boolean'){ + if(rawDefault===undefined) return {ok:true,value:false}; + return typeof rawDefault==='boolean'?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='string'){ + if(rawDefault===undefined) return {ok:true,value:''}; + return typeof rawDefault==='string'?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='number'){ + if(rawDefault===undefined) return {ok:true,value:0}; + return typeof rawDefault==='number'&&Number.isFinite(rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='integer'){ + if(rawDefault===undefined) return {ok:true,value:0}; + return Number.isInteger(rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='enum'&&options){ + if(rawDefault===undefined) return {ok:true,value:options[0].value}; + return typeof rawDefault==='string'&&options.some(option=>option.value===rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + return {ok:false}; + } + + function normalizeSchema(rawSchema){ + const rawFields=Array.isArray(rawSchema)?rawSchema:(rawSchema&&Array.isArray(rawSchema.fields)?rawSchema.fields:[]); + const fields=[]; + const seen=new Set(); + for(const raw of rawFields){ + if(!raw||typeof raw!=='object'||raw.sensitive===true) continue; + const key=typeof raw.key==='string'?raw.key.trim():''; + const type=typeof raw.type==='string'?raw.type.trim().toLowerCase():''; + if(!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(key)||!FIELD_TYPES.has(type)||seen.has(key)) continue; + const options=type==='enum'?enumOptions(raw.options):null; + if(type==='enum'&&!options) continue; + const normalizedDefault=defaultFor(type,raw.default,options); + if(!normalizedDefault.ok) continue; + seen.add(key); + const field={key,type,label:text(raw.label,key),description:text(raw.description,''),default:normalizedDefault.value}; + if(options) field.options=options; + fields.push(field); + } + return fields; + } + + function configure(config){ + const list=Array.isArray(config&&config.extensions)?config.extensions:[]; + schemas.clear(); + for(const entry of list){ + const id=extensionId(entry&&entry.id); + if(!id) continue; + schemas.set(id, { + id, + name:text(entry&&entry.name,id), + storage_owned:!!(entry&&entry.storage_owned), + settings_schema:normalizeSchema(entry&&entry.settings_schema), + }); + } + } + + function safeRead(key){ + try{ + const raw=window.localStorage.getItem(key); + if(!raw) return {}; + const parsed=JSON.parse(raw); + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:{}; + }catch(_e){ + return {}; + } + } + + function safeWrite(key,value){ + const keys=Object.keys(value||{}); + try{ + if(!keys.length) window.localStorage.removeItem(key); + else window.localStorage.setItem(key,JSON.stringify(value)); + return true; + }catch(_e){ + return false; + } + } + + function fieldMap(schema){ + const map=new Map(); + for(const field of schema) map.set(field.key,field); + return map; + } + + function validateValue(field,value){ + if(field.type==='boolean') return typeof value==='boolean'?{ok:true,value}:{ok:false}; + if(field.type==='string') return typeof value==='string'?{ok:true,value}:{ok:false}; + if(field.type==='number') return typeof value==='number'&&Number.isFinite(value)?{ok:true,value}:{ok:false}; + if(field.type==='integer') return Number.isInteger(value)?{ok:true,value}:{ok:false}; + if(field.type==='enum') return typeof value==='string'&&field.options.some(option=>option.value===value)?{ok:true,value}:{ok:false}; + return {ok:false}; + } + + function validate(schema,values){ + const input=values&&typeof values==='object'&&!Array.isArray(values)?values:{}; + const map=fieldMap(schema); + const normalized={}; + const errors={}; + for(const field of schema) normalized[field.key]=field.default; + for(const [key,value] of Object.entries(input)){ + const field=map.get(key); + if(!field) continue; + const checked=validateValue(field,value); + if(checked.ok) normalized[key]=checked.value; + else errors[key]='invalid'; + } + return {ok:Object.keys(errors).length===0,values:normalized,errors}; + } + + function defaultsFor(schema){ + const defaults={}; + for(const field of schema) defaults[field.key]=field.default; + return defaults; + } + + function overridesFromValues(schema,values){ + const overrides={}; + for(const field of schema){ + if(values[field.key]!==field.default) overrides[field.key]=values[field.key]; + } + return overrides; + } + + function settingsForExtension(id){ + const clean=extensionId(id); + const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; + const schema=meta.settings_schema||[]; + const key=settingsKey(clean); + function current(){ + return validate(schema,safeRead(key)).values; + } + function setAll(values){ + const checked=validate(schema,values); + if(!checked.ok) return checked; + const saved=safeWrite(key,overridesFromValues(schema,checked.values)); + return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}}; + } + return { + extensionId:clean, + schema, + defaults:defaultsFor(schema), + get values(){return current();}, + get overrides(){return safeRead(key);}, + get(name){return current()[name];}, + validate(values){return validate(schema,values);}, + set(name,value){ + if(name&&typeof name==='object') return setAll(name); + const next=current(); + next[name]=value; + return setAll(next); + }, + setAll, + reset(){ + safeWrite(key,{}); + return current(); + }, + clear(){ + safeWrite(key,{}); + return true; + }, + }; + } + + function storageForExtension(id){ + const key=storageKey(id); + return { + getAll(){return safeRead(key);}, + get(name,defaultValue){ + const data=safeRead(key); + return Object.prototype.hasOwnProperty.call(data,name)?data[name]:defaultValue; + }, + set(name,value){ + const data=safeRead(key); + data[name]=value; + return safeWrite(key,data); + }, + remove(name){ + const data=safeRead(key); + delete data[name]; + return safeWrite(key,data); + }, + clear(){ + safeWrite(key,{}); + return true; + }, + }; + } + + const api={ + configure, + normalizeSchema, + namespaceForExtension, + settingsForExtension, + storageForExtension, + resetSettingsForExtension(id){return settingsForExtension(id).reset();}, + clearStorageForExtension(id){return storageForExtension(id).clear();}, + }; + + window.HermesExtensionSettings=api; + window.hermesExt=window.hermesExt||{}; + window.hermesExt.settings=window.hermesExt.settings||{}; + window.hermesExt.storage=window.hermesExt.storage||{}; + window.hermesExt.settings.forExtension=settingsForExtension; + window.hermesExt.storage.forExtension=storageForExtension; + configure(window.__HERMES_EXTENSION_CONFIG__||{}); +})(); diff --git a/static/index.html b/static/index.html index 8287fc6a8..24da89160 100644 --- a/static/index.html +++ b/static/index.html @@ -1452,7 +1452,7 @@
+ ${_extensionSettingsControls(entry)} `; @@ -8374,6 +8433,7 @@ function _renderExtensionsPanel(data,seq){ const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; _extensionsStatusData=data||null; + _configureExtensionSettingsFromStatus(data); if(copyBtn) copyBtn.disabled=!data; const manifest=(data&&data.manifest)||{}; const counts=(data&&data.counts)||{}; @@ -8454,6 +8514,7 @@ function _renderExtensionsPanel(data,seq){ `; _bindExtensionToggleButtons(target); + _bindExtensionSettingsButtons(target); _monitorExtensionSidecars(sidecars,seq); } @@ -8483,6 +8544,75 @@ async function handleExtensionToggle(btn){ } } +function _readExtensionSettingsForm(row){ + const values={}; + row.querySelectorAll('[data-extension-setting-input]').forEach(input=>{ + const key=input.dataset.extensionSettingInput||''; + const type=input.dataset.extensionSettingType||''; + if(!key) return; + if(type==='boolean') values[key]=!!input.checked; + else if(type==='integer') values[key]=Number.parseInt(input.value,10); + else if(type==='number') values[key]=Number.parseFloat(input.value); + else values[key]=input.value; + }); + return values; +} + +function _fillExtensionSettingsForm(row,id){ + if(!window.HermesExtensionSettings) return; + const values=window.HermesExtensionSettings.settingsForExtension(id).values; + row.querySelectorAll('[data-extension-setting-input]').forEach(input=>{ + const key=input.dataset.extensionSettingInput||''; + const type=input.dataset.extensionSettingType||''; + const value=values[key]; + if(type==='boolean') input.checked=!!value; + else input.value=value??''; + }); +} + +function _bindExtensionSettingsButtons(root){ + if(!root) return; + root.querySelectorAll('[data-extension-settings-save]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionSettingsSave(btn)); + }); + root.querySelectorAll('[data-extension-settings-reset]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionSettingsReset(btn)); + }); + root.querySelectorAll('[data-extension-storage-clear]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionStorageClear(btn)); + }); +} + +function handleExtensionSettingsSave(btn){ + const id=btn&&btn.dataset.extensionSettingsSave; + const row=btn&&btn.closest('[data-extension-id]'); + if(!id||!row||!window.HermesExtensionSettings) return; + const api=window.HermesExtensionSettings.settingsForExtension(id); + const result=api.setAll(_readExtensionSettingsForm(row)); + if(!result.ok){ + showToast('Extension settings contain invalid values.'); + return; + } + _fillExtensionSettingsForm(row,id); + showToast('Extension settings saved in this browser.'); +} + +function handleExtensionSettingsReset(btn){ + const id=btn&&btn.dataset.extensionSettingsReset; + const row=btn&&btn.closest('[data-extension-id]'); + if(!id||!row||!window.HermesExtensionSettings) return; + window.HermesExtensionSettings.settingsForExtension(id).reset(); + _fillExtensionSettingsForm(row,id); + showToast('Extension settings reset in this browser.'); +} + +function handleExtensionStorageClear(btn){ + const id=btn&&btn.dataset.extensionStorageClear; + if(!id||!window.HermesExtensionSettings) return; + window.HermesExtensionSettings.storageForExtension(id).clear(); + showToast('Extension storage cleared in this browser.'); +} + async function loadExtensionsPanel(opts){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); @@ -8702,6 +8832,7 @@ async function loadExtensionsGallery(){ function _renderExtensionsGallery(entries,statusData){ const galleryEl=$('extensionsGallery'); const installedEl=$('extensionsInstalled'); + _configureExtensionSettingsFromStatus(statusData); const installedIds=new Set(); if(statusData&&statusData.gallery_installed){ Object.keys(statusData.gallery_installed).forEach(id=>installedIds.add(id)); @@ -8711,11 +8842,14 @@ function _renderExtensionsGallery(entries,statusData){ } if(!Array.isArray(entries)||entries.length===0){ if(galleryEl) galleryEl.innerHTML='
'; - if(installedEl) installedEl.innerHTML='
'; + if(installedEl){ + installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); + _bindExtensionToggleButtons(installedEl); + _bindExtensionSettingsButtons(installedEl); + } return; } const galleryCards=[]; - const installedCards=[]; for(const entry of entries){ const id=esc(String(entry.id||'')); const name=esc(String(entry.name||entry.id||'')); @@ -8754,10 +8888,13 @@ function _renderExtensionsGallery(entries,statusData){
`; galleryCards.push(card); - if(isInstalled) installedCards.push(card); } if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'
'; - if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'
'; + if(installedEl){ + installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); + _bindExtensionToggleButtons(installedEl); + _bindExtensionSettingsButtons(installedEl); + } _bindExtensionGalleryButtons(entries); } diff --git a/static/style.css b/static/style.css index 13d8c5aaf..bb71ce72f 100644 --- a/static/style.css +++ b/static/style.css @@ -5124,6 +5124,16 @@ main.main > #mainPlugin{display:none;} .extension-installed-meta code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} .extension-toggle-btn{white-space:nowrap;flex:0 0 auto;} .extension-toggle-btn:disabled{opacity:.55;cursor:not-allowed;} +.extension-settings-box{margin-top:8px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card-bg,var(--sidebar-bg));} +.extension-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px;} +.extension-settings-title{font-size:12px;font-weight:700;color:var(--text);} +.extension-settings-note,.extension-settings-empty,.extension-setting-desc{font-size:11px;color:var(--muted);line-height:1.4;} +.extension-settings-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;} +.extension-setting-field label{display:flex;flex-direction:column;gap:4px;font-size:11px;font-weight:600;color:var(--muted);} +.extension-setting-field input,.extension-setting-field select{min-height:30px;border:1px solid var(--border2);border-radius:7px;background:var(--bg);color:var(--text);padding:4px 8px;font-size:12px;} +.extension-setting-check{flex-direction:row!important;align-items:center;font-size:12px!important;color:var(--text)!important;} +.extension-setting-check input{min-height:auto;padding:0;} +.extension-settings-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px;} .extension-sidecar-list{display:flex;flex-direction:column;gap:8px;} .extension-sidecar-row{padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;} .extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index 5edd2de45..c6ef0031a 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -126,6 +126,40 @@ def test_index_html_injection_escapes_urls_and_preserves_disabled_default(tmp_pa assert injected.index("/extensions/app.js") < injected.index("") +def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "manifest.json").write_text( + """ + { + "extensions": [ + { + "id": "settings-ok", + "scripts": ["settings-ok.js"], + "permissions": {"storage": {"owned": true}}, + "settings_schema": [ + {"key": "flag", "type": "boolean", "default": true} + ] + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") + + from api.extensions import inject_extension_tags + + injected = inject_extension_tags("