From a76c794d6deafdc8fcb83a1706c4ef5c80bf2620 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 17:28:20 -0400 Subject: [PATCH 1/8] feat(extensions): add browser-local extension settings surface (#5094) --- api/extensions.py | 169 ++++++++++++++- docs/EXTENSIONS.md | 64 ++++++ static/extension_settings.js | 264 +++++++++++++++++++++++ static/index.html | 3 +- static/panels.js | 145 ++++++++++++- static/style.css | 10 + tests/test_extension_hooks.py | 54 +++++ tests/test_extension_settings_runtime.py | 80 +++++++ tests/test_extension_status_endpoint.py | 74 +++++++ tests/test_extensions_settings_panel.py | 41 ++++ 10 files changed, 895 insertions(+), 9 deletions(-) create mode 100644 static/extension_settings.js create mode 100644 tests/test_extension_settings_runtime.py diff --git a/api/extensions.py b/api/extensions.py index d3d3678df..a135e17db 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -72,8 +72,10 @@ _EXTENSION_STATE_FILENAME = "extension-overrides.json" _MAX_EXTENSION_STATE_BYTES = 32 * 1024 _MAX_DISABLED_EXTENSION_IDS = 512 _EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_EXTENSION_SETTINGS_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,63}$") _EXTENSION_STATE_WARNING_SOURCE = "extension_state" _EXTENSION_STATE_LOCK = threading.Lock() +_EXTENSION_SETTING_TYPES = {"boolean", "string", "number", "integer", "enum"} _GALLERY_INSTALL_STATE_FILENAME = "extension-install-manifest.json" _MAX_INSTALL_MANIFEST_BYTES = 128 * 1024 @@ -521,6 +523,116 @@ def _manifest_entry_text(entry: Dict[str, object], key: str) -> str: return "" return value.strip() +def _manifest_entry_storage_owned(entry: Dict[str, object]) -> bool: + permissions = entry.get("permissions") + if not isinstance(permissions, dict): + return False + storage = permissions.get("storage") + return isinstance(storage, dict) and storage.get("owned") is True + +def _settings_text(value: object, *, max_len: int = 160) -> str: + if not isinstance(value, str): + return "" + text = value.strip() + return text[:max_len] + +def _normalize_enum_options(options: object) -> Optional[List[Dict[str, str]]]: + if not isinstance(options, list) or not options: + return None + normalized: List[Dict[str, str]] = [] + seen: Set[str] = set() + for option in options: + if isinstance(option, str): + value = option.strip() + label = value + elif isinstance(option, dict): + raw_value = option.get("value") + if not isinstance(raw_value, str): + return None + value = raw_value.strip() + label = _settings_text(option.get("label")) or value + else: + return None + if not value or value in seen: + return None + seen.add(value) + normalized.append({"value": value, "label": label}) + return normalized + +def _normalize_settings_default(field_type: str, raw_default: object, options: Optional[List[Dict[str, str]]] = None) -> Tuple[bool, object]: + if field_type == "boolean": + if raw_default is None: + return True, False + return (True, raw_default) if isinstance(raw_default, bool) else (False, None) + if field_type == "string": + if raw_default is None: + return True, "" + return (True, raw_default) if isinstance(raw_default, str) else (False, None) + if field_type == "number": + if raw_default is None: + return True, 0 + return (True, raw_default) if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) else (False, None) + if field_type == "integer": + if raw_default is None: + return True, 0 + return (True, raw_default) if isinstance(raw_default, int) and not isinstance(raw_default, bool) else (False, None) + if field_type == "enum" and options: + values = [option["value"] for option in options] + if raw_default is None: + return True, values[0] + return (True, raw_default) if isinstance(raw_default, str) and raw_default in values else (False, None) + return False, None + +def _settings_schema_values(raw_schema: object) -> List[object]: + if isinstance(raw_schema, list): + return raw_schema + if isinstance(raw_schema, dict) and isinstance(raw_schema.get("fields"), list): + return raw_schema["fields"] + return [] + +def _sanitize_settings_schema(entry: Dict[str, object]) -> List[Dict[str, object]]: + if not _manifest_entry_storage_owned(entry): + return [] + fields: List[Dict[str, object]] = [] + seen_keys: Set[str] = set() + for raw_field in _settings_schema_values(entry.get("settings_schema")): + if not isinstance(raw_field, dict): + continue + if raw_field.get("sensitive") is True: + continue + key = raw_field.get("key") + if not isinstance(key, str): + continue + key = key.strip() + if not _EXTENSION_SETTINGS_KEY_RE.fullmatch(key): + continue + field_type = raw_field.get("type") + if not isinstance(field_type, str): + continue + field_type = field_type.strip().lower() + if field_type not in _EXTENSION_SETTING_TYPES: + continue + options = _normalize_enum_options(raw_field.get("options")) if field_type == "enum" else None + if field_type == "enum" and options is None: + continue + ok, default = _normalize_settings_default(field_type, raw_field.get("default"), options) + if not ok: + continue + if key in seen_keys: + continue + seen_keys.add(key) + field: Dict[str, object] = { + "key": key, + "type": field_type, + "label": _settings_text(raw_field.get("label")) or key, + "description": _settings_text(raw_field.get("description"), max_len=300), + "default": default, + } + if options is not None: + field["options"] = options + fields.append(field) + return fields + def _normalize_loopback_sidecar_origin(value: object) -> Optional[str]: """Return a canonical loopback origin or None when unsafe. @@ -735,7 +847,7 @@ def _gallery_installed_runtime_manifest( asset_base = ext_id if isinstance(manifest, dict): top_entry: Dict[str, object] = {"id": ext_id} - for key in ("name", "enabled", "scripts", "stylesheets", "sidecar"): + for key in ("name", "enabled", "scripts", "stylesheets", "sidecar", "permissions", "settings_schema"): if key in manifest: top_entry[key] = manifest[key] if any(key in top_entry for key in ("scripts", "stylesheets", "sidecar")): @@ -836,6 +948,7 @@ def _manifest_extension_state( effective_enabled = manifest_enabled and not user_disabled if not manifest_enabled: manifest_disabled_ids.add(ext_id) + settings_schema = _sanitize_settings_schema(entry) extension_entries.append( { "id": ext_id, @@ -846,6 +959,8 @@ def _manifest_extension_state( "effective_enabled": effective_enabled, "can_toggle": can_toggle, "reload_required": True, + "storage_owned": _manifest_entry_storage_owned(entry), + "settings_schema": settings_schema, "status": ( "manifest_disabled" if not manifest_enabled @@ -866,6 +981,33 @@ def _manifest_extension_state( "manifest_disabled_ids": manifest_disabled_ids, } +def _extension_runtime_entries( + manifest: object, disabled_ids: Optional[Set[str]] = None +) -> List[Dict[str, object]]: + """Return enabled extension metadata injected before extension scripts run.""" + disabled_ids = disabled_ids or set() + extensions: List[Dict[str, object]] = [] + seen_ids: Set[str] = set() + for _source, _index, entry in _manifest_extension_entries(manifest): + raw_id = _manifest_entry_text(entry, "id") + if not _valid_extension_id(raw_id): + continue + ext_id = raw_id.strip() + if ext_id in seen_ids: + continue + seen_ids.add(ext_id) + if entry.get("enabled", True) is False or ext_id in disabled_ids: + continue + extensions.append( + { + "id": ext_id, + "name": _manifest_entry_text(entry, "name") or ext_id, + "storage_owned": _manifest_entry_storage_owned(entry), + "settings_schema": _sanitize_settings_schema(entry), + } + ) + return extensions + def _read_manifest_urls_with_diagnostics( root: Path, @@ -950,8 +1092,14 @@ def get_extension_config() -> Dict[str, Any]: return {"enabled": False, "script_urls": [], "stylesheet_urls": []} state = _load_extension_state() disabled_ids = set(state.get("disabled_extensions") or []) - manifest_scripts, manifest_stylesheets = _read_manifest_urls(root, disabled_ids=disabled_ids) - return { + manifest, manifest_status = _load_manifest_with_status(root) + manifest_scripts, manifest_stylesheets, _, _ = _read_manifest_urls_with_diagnostics( + root, + disabled_ids=disabled_ids, + manifest=manifest, + manifest_status=manifest_status, + ) + config = { "enabled": True, "script_urls": _read_url_list( _EXTENSION_SCRIPT_URLS_ENV, manifest_scripts or None @@ -960,6 +1108,10 @@ def get_extension_config() -> Dict[str, Any]: _EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None ), } + runtime_entries = _extension_runtime_entries(manifest, disabled_ids) if manifest is not None else [] + if runtime_entries: + config["extensions"] = runtime_entries + return config @@ -1387,6 +1539,15 @@ def inject_extension_tags(index_html: str) -> str: ''.format(html.escape(url, quote=True)) for url in config["script_urls"] ] + runtime_config = { + "extensions": config.get("extensions", []), + } + runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") + runtime_tag = ( + "" + ).format(runtime_json) if stylesheet_tags: head_marker = "" @@ -1398,7 +1559,7 @@ def inject_extension_tags(index_html: str) -> str: if script_tags: body_marker = "" - 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 @@
-
Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.
+
Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Settings and extension-owned storage are browser-local and not for secrets. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.
Loading extension diagnostics…
@@ -1682,6 +1682,7 @@ + diff --git a/static/panels.js b/static/panels.js index 397ce14f8..aa0ffed09 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8168,6 +8168,64 @@ function _extensionEntryBadge(entry){ return `${esc(_extensionEntryStatusLabel(entry))}`; } +function _configureExtensionSettingsFromStatus(data){ + if(!window.HermesExtensionSettings||!data||!Array.isArray(data.extensions)) return; + window.HermesExtensionSettings.configure({extensions:data.extensions}); +} + +function _extensionSettingsFieldHtml(field,value){ + const key=String(field&&field.key||''); + const type=String(field&&field.type||''); + const label=String(field&&field.label||key); + const desc=String(field&&field.description||''); + const dataAttrs=`data-extension-setting-input="${esc(key)}" data-extension-setting-type="${esc(type)}"`; + let control=''; + if(type==='boolean'){ + control=``; + }else if(type==='number'||type==='integer'){ + const step=type==='integer'?'1':'any'; + control=``; + }else if(type==='enum'){ + const options=Array.isArray(field.options)?field.options:[]; + control=``; + }else{ + control=``; + } + return `
${control}${desc?`
${esc(desc)}
`:''}
`; +} + +function _extensionSettingsControls(entry){ + const id=(entry&&entry.id)||''; + const schema=Array.isArray(entry&&entry.settings_schema)?entry.settings_schema:[]; + const storageOwned=!!(entry&&entry.storage_owned); + if(!storageOwned){ + return '
No extension-owned browser storage permission.
'; + } + const settingsApi=window.HermesExtensionSettings&&id?window.HermesExtensionSettings.settingsForExtension(id):null; + const values=settingsApi?settingsApi.values:{}; + const fields=schema.length + ? schema.map(field=>_extensionSettingsFieldHtml(field,values[field.key])).join('') + : '
No configurable settings declared.
'; + return `
+
+
+
Browser-local extension settings
+
Settings and extension-owned storage stay in this browser. Do not store secrets here.
+
+
+
${fields}
+
+ + + +
+
`; +} + function _extensionInstalledList(extensions,extensionDirConfigured){ const list=Array.isArray(extensions)?extensions:[]; if(!list.length){ @@ -8192,6 +8250,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){ ${_extensionEntryBadge(entry)}
${esc(id)}${esc(note)}
+ ${_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='
No extensions found in the registry.
'; - if(installedEl) installedEl.innerHTML='
No extensions installed from the gallery.
'; + 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(''):'
No extensions found.
'; - if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'
No extensions installed from the gallery.
'; + 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("") + + assert "window.__HERMES_EXTENSION_CONFIG__" in injected + assert "window.HermesExtensionSettings.configure(window.__HERMES_EXTENSION_CONFIG__)" in injected + assert '"storage_owned":true' in injected + assert '"settings_schema":[{"key":"flag","type":"boolean"' in injected + assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js") + + def test_extension_route_remains_behind_webui_auth(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") @@ -198,6 +232,14 @@ def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkey "/extensions/templates/app.css", "/extensions/env-only.css", ], + "extensions": [ + { + "id": "templates", + "name": "templates", + "storage_owned": False, + "settings_schema": [], + } + ], } @@ -231,6 +273,14 @@ def test_extension_manifest_relative_assets_resolve_from_manifest_directory(tmp_ "enabled": True, "script_urls": ["/extensions/desktop-companion/assets/companion-adapter.js"], "stylesheet_urls": ["/extensions/desktop-companion/assets/companion-adapter.css"], + "extensions": [ + { + "id": "desktop-companion", + "name": "desktop-companion", + "storage_owned": False, + "settings_schema": [], + } + ], } @@ -537,6 +587,10 @@ def test_extension_manifest_accepts_top_level_extension_array(tmp_path, monkeypa "enabled": True, "script_urls": ["/extensions/a/a.js", "/extensions/b/b.js"], "stylesheet_urls": ["/extensions/a/a.css"], + "extensions": [ + {"id": "a", "name": "a", "storage_owned": False, "settings_schema": []}, + {"id": "b", "name": "b", "storage_owned": False, "settings_schema": []}, + ], } def test_extension_static_serving_is_sandboxed(tmp_path, monkeypatch): diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py new file mode 100644 index 000000000..b59db3376 --- /dev/null +++ b/tests/test_extension_settings_runtime.py @@ -0,0 +1,80 @@ +"""Runtime tests for browser-local extension settings.""" + +from pathlib import Path +import shutil +import subprocess +import textwrap + +import pytest + + +ROOT = Path(__file__).parent.parent +EXTENSION_SETTINGS_JS = ROOT / "static" / "extension_settings.js" + + +def _run_node(script: str): + node = shutil.which("node") + if not node: + pytest.skip("node is required for extension settings runtime tests") + result = subprocess.run( + [node, "-e", script], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + ) + assert result.returncode == 0, result.stderr + result.stdout + + +def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): + script = textwrap.dedent( + f""" + const fs = require('fs'); + const assert = require('assert'); + const store = new Map(); + global.window = {{ + localStorage: {{ + getItem(key) {{ return store.has(key) ? store.get(key) : null; }}, + setItem(key, value) {{ store.set(key, String(value)); }}, + removeItem(key) {{ store.delete(key); }} + }} + }}; + eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8')); + + window.HermesExtensionSettings.configure({{ + extensions: [{{ + id: 'demo.ext', + name: 'Demo', + storage_owned: true, + settings_schema: [ + {{key: 'flag', type: 'boolean', default: false}}, + {{key: 'mode', type: 'enum', options: ['compact', {{value: 'full', label: 'Full'}}], default: 'compact'}}, + {{key: 'count', type: 'integer', default: 2}}, + {{key: 'secret', type: 'string', sensitive: true, default: 'x'}}, + {{key: 'bad', type: 'enum', options: [{{label: 'missing value'}}]}}, + {{key: 'flag', type: 'boolean', default: true}} + ] + }}] + }}); + + const settings = window.HermesExtensionSettings.settingsForExtension('demo.ext'); + assert.deepStrictEqual(settings.schema.map(field => field.key), ['flag', 'mode', 'count']); + assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); + assert.strictEqual(window.hermesExt.settings.forExtension('demo.ext').get('mode'), 'compact'); + assert.deepStrictEqual(settings.setAll({{flag: true, mode: 'compact', count: 2}}).values, {{flag: true, mode: 'compact', count: 2}}); + assert.deepStrictEqual(JSON.parse(store.get('hermes.ext.settings.demo.ext')), {{flag: true}}); + assert.strictEqual(settings.set('mode', 'invalid').ok, false); + assert.deepStrictEqual(settings.reset(), {{flag: false, mode: 'compact', count: 2}}); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); + + const storage = window.HermesExtensionSettings.storageForExtension('demo.ext'); + assert.strictEqual(window.hermesExt.storage.forExtension('demo.ext').set('note', 'local'), true); + assert.strictEqual(storage.get('note'), 'local'); + assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), true); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); + assert.strictEqual(storage.clear(), true); + assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), false); + """ + ) + _run_node(script) diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index 3b14dbc8f..9d8243c36 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -164,6 +164,8 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "effective_enabled": True, "can_toggle": True, "reload_required": True, + "storage_owned": False, + "settings_schema": [], "status": "enabled", }, { @@ -175,6 +177,8 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "effective_enabled": False, "can_toggle": False, "reload_required": True, + "storage_owned": False, + "settings_schema": [], "status": "manifest_disabled", }, ] @@ -190,6 +194,76 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk assert status["warnings"] == [] +def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "extensions.json").write_text( + json.dumps( + { + "extensions": [ + { + "id": "settings-ok", + "permissions": {"storage": {"owned": True}}, + "settings_schema": [ + {"key": "flag", "type": "boolean", "label": "Flag", "default": True}, + {"key": "name", "type": "string", "default": "Ada"}, + {"key": "ratio", "type": "number", "default": 1.5}, + {"key": "count", "type": "integer", "default": 2}, + { + "key": "mode", + "type": "enum", + "options": [ + {"value": "compact", "label": "Compact"}, + "full", + ], + "default": "compact", + }, + {"key": "secret", "type": "string", "sensitive": True, "default": "x"}, + {"key": "badtype", "type": "object", "default": {}}, + {"key": "bad_enum", "type": "enum", "options": [{"label": "No value"}]}, + {"key": "flag", "type": "boolean", "default": False}, + {"key": "bad_default", "type": "integer", "default": 1.2}, + ], + }, + { + "id": "settings-denied", + "permissions": {"storage": {"owned": False}}, + "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", "extensions.json") + + from api.extensions import get_extension_config, get_extension_status + + status = get_extension_status() + by_id = {entry["id"]: entry for entry in status["extensions"]} + assert by_id["settings-ok"]["storage_owned"] is True + assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == [ + "flag", + "name", + "ratio", + "count", + "mode", + ] + assert by_id["settings-ok"]["settings_schema"][0]["default"] is True + assert by_id["settings-ok"]["settings_schema"][4]["options"] == [ + {"value": "compact", "label": "Compact"}, + {"value": "full", "label": "full"}, + ] + assert by_id["settings-denied"]["storage_owned"] is False + assert by_id["settings-denied"]["settings_schema"] == [] + + config = get_extension_config() + config_by_id = {entry["id"]: entry for entry in config["extensions"]} + assert config_by_id["settings-ok"]["settings_schema"] == by_id["settings-ok"]["settings_schema"] + assert config_by_id["settings-denied"]["settings_schema"] == [] + + def test_extension_status_ignores_non_dict_manifest_extensions_in_entry_count( tmp_path, monkeypatch ): diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index 956f0ff77..fa23c1faf 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -9,6 +9,7 @@ PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") DOCS_EXTENSIONS = (ROOT / "docs" / "EXTENSIONS.md").read_text(encoding="utf-8") +ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") def _function_block(name: str, *, extra: int = 2200) -> str: @@ -64,6 +65,7 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free(): pane = INDEX_HTML[pane_start:pane_end] assert "Extensions run in the WebUI browser origin" in pane + assert "Settings and extension-owned storage are browser-local and not for secrets" in pane assert "Only load trusted local extension directories" in pane assert "takes effect after reload" in pane assert "copyExtensionsDiagnostics()" in pane @@ -71,6 +73,8 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free(): assert "api('/api/settings'" not in pane assert "type=\"checkbox\"" not in pane assert "marketplace" not in pane.lower() + assert "static/extension_settings.js" in INDEX_HTML + assert INDEX_HTML.index("static/extension_settings.js") < INDEX_HTML.index("static/panels.js") def test_switch_settings_section_supports_extensions_lazy_load(): @@ -102,6 +106,13 @@ def test_extensions_panel_fetches_status_endpoint_without_mutating_settings(): assert "extensions-error" in load_block +def test_extensions_do_not_add_generic_backend_settings_write_route(): + assert "/api/extensions/settings" not in ROUTES_PY + assert "/api/extensions/storage" not in ROUTES_PY + assert "set_extension_settings" not in ROUTES_PY + assert "write_extension_settings" not in ROUTES_PY + + def test_extensions_diagnostics_tab_refreshes_runtime_status(): tab_block = _function_block("switchExtensionsTab", extra=900) @@ -213,6 +224,30 @@ def test_extensions_panel_toggle_uses_dedicated_endpoint_without_settings_or_ins assert "marketplace" not in combined.lower() +def test_extensions_installed_settings_route_through_shared_accessor(): + installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge") + settings_block = _between("function _configureExtensionSettingsFromStatus", "function _extensionInstalledList") + bind_block = _between("function _bindExtensionSettingsButtons", "async function loadExtensionsPanel") + gallery_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons") + + assert "entry&&entry.settings_schema" in settings_block + assert "entry&&entry.storage_owned" in settings_block + assert "window.HermesExtensionSettings.settingsForExtension(id)" in settings_block + assert "data-extension-settings-save" in settings_block + assert "data-extension-settings-reset" in settings_block + assert "data-extension-storage-clear" in settings_block + assert "Browser-local extension settings" in settings_block + assert "Do not store secrets here" in settings_block + assert "_extensionSettingsControls(entry)" in installed_block + assert "window.HermesExtensionSettings.settingsForExtension(id).reset()" in bind_block + assert "window.HermesExtensionSettings.storageForExtension(id).clear()" in bind_block + assert "api('/api/extensions/status')" not in bind_block + assert "api('/api/settings'" not in bind_block + assert "localStorage" not in bind_block + assert "_extensionInstalledList(statusData&&statusData.extensions" in gallery_block + assert "_bindExtensionSettingsButtons(installedEl)" in gallery_block + + def test_extensions_gallery_renders_post_install_guidance(): url_block = _between("function _extensionSafeHttpUrl", "function _extensionPostInstallNote") gallery_block = _between("function _extensionPostInstallNote", "async function loadExtensionsGallery") @@ -292,6 +327,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel(): assert ".extension-url-list" in STYLE_CSS assert ".extension-installed-list" in STYLE_CSS assert ".extension-toggle-btn" in STYLE_CSS + assert ".extension-settings-box" in STYLE_CSS + assert ".extension-settings-actions" in STYLE_CSS assert ".extension-sidecar-list" in STYLE_CSS assert ".extension-sidecar-runtime" in STYLE_CSS assert ".extension-sidecar-status-badge" in STYLE_CSS @@ -383,6 +420,10 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims assert "does **not** proxy sidecar requests" in diagnostics_section assert "optional top-level `runtime` object" in diagnostics_section assert "allowlisted scalar fields" in diagnostics_section + assert "browser-local controls" in diagnostics_section + assert "`window.HermesExtensionSettings`" in DOCS_EXTENSIONS + assert "does not store extension settings or expose a generic settings write route" in DOCS_EXTENSIONS + assert "Settings persist only non-default overrides" in DOCS_EXTENSIONS assert "do **not**" in diagnostics_section assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section assert "override state-file path" in diagnostics_section From 07ed67a793fa1e0615c2b8bec6379d58d46eadc7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 17:44:02 -0400 Subject: [PATCH 2/8] fix(extensions): keep runtime settings gated on owned storage (#5094) --- api/extensions.py | 23 ++++++++---- static/extension_settings.js | 47 ++++++++++++++++++------ static/panels.js | 2 +- tests/test_extension_hooks.py | 2 +- tests/test_extension_settings_runtime.py | 19 +++++++++- tests/test_extension_status_endpoint.py | 10 ++--- 6 files changed, 73 insertions(+), 30 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index a135e17db..e822d1d47 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -7,6 +7,7 @@ It is disabled by default and never executes or fetches third-party URLs. import html import json +import math import logging import os import re @@ -559,26 +560,32 @@ def _normalize_enum_options(options: object) -> Optional[List[Dict[str, str]]]: normalized.append({"value": value, "label": label}) return normalized +_SETTINGS_DEFAULT_MISSING = object() + def _normalize_settings_default(field_type: str, raw_default: object, options: Optional[List[Dict[str, str]]] = None) -> Tuple[bool, object]: if field_type == "boolean": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, False return (True, raw_default) if isinstance(raw_default, bool) else (False, None) if field_type == "string": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, "" return (True, raw_default) if isinstance(raw_default, str) else (False, None) if field_type == "number": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, 0 - return (True, raw_default) if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) else (False, None) + return ( + (True, raw_default) + if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) and math.isfinite(raw_default) + else (False, None) + ) if field_type == "integer": - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, 0 return (True, raw_default) if isinstance(raw_default, int) and not isinstance(raw_default, bool) else (False, None) if field_type == "enum" and options: values = [option["value"] for option in options] - if raw_default is None: + if raw_default is _SETTINGS_DEFAULT_MISSING: return True, values[0] return (True, raw_default) if isinstance(raw_default, str) and raw_default in values else (False, None) return False, None @@ -615,7 +622,7 @@ def _sanitize_settings_schema(entry: Dict[str, object]) -> List[Dict[str, object options = _normalize_enum_options(raw_field.get("options")) if field_type == "enum" else None if field_type == "enum" and options is None: continue - ok, default = _normalize_settings_default(field_type, raw_field.get("default"), options) + ok, default = _normalize_settings_default(field_type, raw_field.get("default", _SETTINGS_DEFAULT_MISSING), options) if not ok: continue if key in seen_keys: @@ -1545,7 +1552,7 @@ def inject_extension_tags(index_html: str) -> str: runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") runtime_tag = ( "" ).format(runtime_json) diff --git a/static/extension_settings.js b/static/extension_settings.js index 2315d2744..406d98623 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -97,19 +97,28 @@ return fields; } - function configure(config){ - const list=Array.isArray(config&&config.extensions)?config.extensions:[]; - schemas.clear(); + function normalizeSchemas(rawRows){ + const list=Array.isArray(rawRows)?rawRows:[]; + const entries=[]; for(const entry of list){ const id=extensionId(entry&&entry.id); if(!id) continue; - schemas.set(id, { + const storageOwned=!!(entry&&entry.storage_owned); + entries.push({ id, name:text(entry&&entry.name,id), - storage_owned:!!(entry&&entry.storage_owned), - settings_schema:normalizeSchema(entry&&entry.settings_schema), + storage_owned:storageOwned, + settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[], }); } + return entries; + } + + function primeFromStatus(statusPayload){ + schemas.clear(); + for(const entry of normalizeSchemas(statusPayload&&statusPayload.extensions)){ + schemas.set(entry.id,entry); + } } function safeRead(key){ @@ -179,15 +188,20 @@ return overrides; } + function supportsSettings(meta){ + return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length); + } + 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 schema=supportsSettings(meta)?meta.settings_schema:[]; const key=settingsKey(clean); function current(){ return validate(schema,safeRead(key)).values; } function setAll(values){ + if(!supportsSettings(meta)) return {ok:false,values:current(),errors:{extension:'unsupported'}}; const checked=validate(schema,values); if(!checked.ok) return checked; const saved=safeWrite(key,overridesFromValues(schema,checked.values)); @@ -209,10 +223,12 @@ }, setAll, reset(){ + if(!supportsSettings(meta)) return current(); safeWrite(key,{}); return current(); }, clear(){ + if(!supportsSettings(meta)) return false; safeWrite(key,{}); return true; }, @@ -220,24 +236,31 @@ } function storageForExtension(id){ - const key=storageKey(id); + const clean=extensionId(id); + const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; + const allowed=!!meta.storage_owned; + const key=storageKey(clean); return { - getAll(){return safeRead(key);}, + getAll(){return allowed?safeRead(key):{};}, get(name,defaultValue){ + if(!allowed) return defaultValue; const data=safeRead(key); return Object.prototype.hasOwnProperty.call(data,name)?data[name]:defaultValue; }, set(name,value){ + if(!allowed) return false; const data=safeRead(key); data[name]=value; return safeWrite(key,data); }, remove(name){ + if(!allowed) return false; const data=safeRead(key); delete data[name]; return safeWrite(key,data); }, clear(){ + if(!allowed) return false; safeWrite(key,{}); return true; }, @@ -245,8 +268,8 @@ } const api={ - configure, - normalizeSchema, + normalizeSchemas, + primeFromStatus, namespaceForExtension, settingsForExtension, storageForExtension, @@ -260,5 +283,5 @@ window.hermesExt.storage=window.hermesExt.storage||{}; window.hermesExt.settings.forExtension=settingsForExtension; window.hermesExt.storage.forExtension=storageForExtension; - configure(window.__HERMES_EXTENSION_CONFIG__||{}); + primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{}); })(); diff --git a/static/panels.js b/static/panels.js index aa0ffed09..38035f88b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8170,7 +8170,7 @@ function _extensionEntryBadge(entry){ function _configureExtensionSettingsFromStatus(data){ if(!window.HermesExtensionSettings||!data||!Array.isArray(data.extensions)) return; - window.HermesExtensionSettings.configure({extensions:data.extensions}); + window.HermesExtensionSettings.primeFromStatus({extensions:data.extensions}); } function _extensionSettingsFieldHtml(field,value){ diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index c6ef0031a..a2371978d 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -154,7 +154,7 @@ def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_ injected = inject_extension_tags("") assert "window.__HERMES_EXTENSION_CONFIG__" in injected - assert "window.HermesExtensionSettings.configure(window.__HERMES_EXTENSION_CONFIG__)" in injected + assert "window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__)" in injected assert '"storage_owned":true' in injected assert '"settings_schema":[{"key":"flag","type":"boolean"' in injected assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js") diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index b59db3376..92e54e117 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -42,7 +42,7 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): }}; eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8')); - window.HermesExtensionSettings.configure({{ + window.HermesExtensionSettings.primeFromStatus({{ extensions: [{{ id: 'demo.ext', name: 'Demo', @@ -75,6 +75,23 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); assert.strictEqual(storage.clear(), true); assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), false); + + window.HermesExtensionSettings.primeFromStatus({{ + extensions: [{{ + id: 'denied.ext', + name: 'Denied', + storage_owned: false, + settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], + }}] + }}); + + const deniedSettings = window.HermesExtensionSettings.settingsForExtension('denied.ext'); + assert.strictEqual(deniedSettings.setAll({{flag: true}}).ok, false); + assert.strictEqual(store.has('hermes.ext.settings.denied.ext'), false); + + const deniedStorage = window.HermesExtensionSettings.storageForExtension('denied.ext'); + assert.strictEqual(deniedStorage.set('note', 'blocked'), false); + assert.strictEqual(store.has('hermes.ext.storage.denied.ext'), false); """ ) _run_node(script) diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index 9d8243c36..0c0358d4f 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -223,6 +223,8 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p {"key": "bad_enum", "type": "enum", "options": [{"label": "No value"}]}, {"key": "flag", "type": "boolean", "default": False}, {"key": "bad_default", "type": "integer", "default": 1.2}, + {"key": "null_flag", "type": "boolean", "default": None}, + {"key": "null_name", "type": "string", "default": None}, ], }, { @@ -243,13 +245,7 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p status = get_extension_status() by_id = {entry["id"]: entry for entry in status["extensions"]} assert by_id["settings-ok"]["storage_owned"] is True - assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == [ - "flag", - "name", - "ratio", - "count", - "mode", - ] + assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == ["flag", "name", "ratio", "count", "mode"] assert by_id["settings-ok"]["settings_schema"][0]["default"] is True assert by_id["settings-ok"]["settings_schema"][4]["options"] == [ {"value": "compact", "label": "Compact"}, From cb96a5cf39a2c02296f5a5227475a1bbb8fe8ace Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 17:51:42 -0400 Subject: [PATCH 3/8] fix(extensions): trust only bootstrapped extension ids (#5094) --- api/extensions.py | 13 +++++- static/extension_settings.js | 25 +++++++++- tests/test_extension_hooks.py | 8 +++- tests/test_extension_settings_runtime.py | 58 ++++++++++++++---------- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index e822d1d47..04bec03ee 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -1115,7 +1115,18 @@ def get_extension_config() -> Dict[str, Any]: _EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None ), } - runtime_entries = _extension_runtime_entries(manifest, disabled_ids) if manifest is not None else [] + runtime_entries: List[Dict[str, object]] = [] + if manifest is not None: + extension_state = _manifest_extension_state(manifest, disabled_ids) + runtime_entries = [ + { + "id": entry["id"], + "name": entry["name"], + "storage_owned": entry["storage_owned"], + "settings_schema": entry["settings_schema"], + } + for entry in extension_state["extensions"] + ] if runtime_entries: config["extensions"] = runtime_entries return config diff --git a/static/extension_settings.js b/static/extension_settings.js index 406d98623..1b269d6e7 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -5,6 +5,8 @@ const STORAGE_PREFIX='hermes.ext.storage.'; const FIELD_TYPES=new Set(['boolean','string','number','integer','enum']); const schemas=new Map(); + const trustedExtensions=new Map(); + let trustedSeeded=false; function extensionId(value){ return String(value||'').trim(); @@ -115,9 +117,28 @@ } function primeFromStatus(statusPayload){ + const entries=normalizeSchemas(statusPayload&&statusPayload.extensions); + if(!trustedSeeded){ + trustedExtensions.clear(); + for(const entry of entries){ + trustedExtensions.set(entry.id,{ + id:entry.id, + name:entry.name, + storage_owned:entry.storage_owned, + }); + } + trustedSeeded=true; + } schemas.clear(); - for(const entry of normalizeSchemas(statusPayload&&statusPayload.extensions)){ - schemas.set(entry.id,entry); + for(const entry of entries){ + const trusted=trustedExtensions.get(entry.id); + if(!trusted) continue; + schemas.set(entry.id,{ + id:entry.id, + name:entry.name, + storage_owned:trusted.storage_owned===true, + settings_schema:trusted.storage_owned===true?entry.settings_schema:[], + }); } } diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index a2371978d..e595c35c1 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -238,7 +238,13 @@ def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkey "name": "templates", "storage_owned": False, "settings_schema": [], - } + }, + { + "id": "disabled", + "name": "disabled", + "storage_owned": False, + "settings_schema": [], + }, ], } diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index 92e54e117..abe764e7d 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -34,6 +34,26 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): const assert = require('assert'); const store = new Map(); global.window = {{ + __HERMES_EXTENSION_CONFIG__: {{ + extensions: [{{ + id: 'demo.ext', + name: 'Demo', + storage_owned: true, + settings_schema: [ + {{key: 'flag', type: 'boolean', default: false}}, + {{key: 'mode', type: 'enum', options: ['compact', {{value: 'full', label: 'Full'}}], default: 'compact'}}, + {{key: 'count', type: 'integer', default: 2}}, + {{key: 'secret', type: 'string', sensitive: true, default: 'x'}}, + {{key: 'bad', type: 'enum', options: [{{label: 'missing value'}}]}}, + {{key: 'flag', type: 'boolean', default: true}} + ] + }}, {{ + id: 'denied.ext', + name: 'Denied', + storage_owned: false, + settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], + }}] + }}, localStorage: {{ getItem(key) {{ return store.has(key) ? store.get(key) : null; }}, setItem(key, value) {{ store.set(key, String(value)); }}, @@ -42,22 +62,6 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): }}; eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8')); - window.HermesExtensionSettings.primeFromStatus({{ - extensions: [{{ - id: 'demo.ext', - name: 'Demo', - storage_owned: true, - settings_schema: [ - {{key: 'flag', type: 'boolean', default: false}}, - {{key: 'mode', type: 'enum', options: ['compact', {{value: 'full', label: 'Full'}}], default: 'compact'}}, - {{key: 'count', type: 'integer', default: 2}}, - {{key: 'secret', type: 'string', sensitive: true, default: 'x'}}, - {{key: 'bad', type: 'enum', options: [{{label: 'missing value'}}]}}, - {{key: 'flag', type: 'boolean', default: true}} - ] - }}] - }}); - const settings = window.HermesExtensionSettings.settingsForExtension('demo.ext'); assert.deepStrictEqual(settings.schema.map(field => field.key), ['flag', 'mode', 'count']); assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); @@ -76,15 +80,6 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.strictEqual(storage.clear(), true); assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), false); - window.HermesExtensionSettings.primeFromStatus({{ - extensions: [{{ - id: 'denied.ext', - name: 'Denied', - storage_owned: false, - settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], - }}] - }}); - const deniedSettings = window.HermesExtensionSettings.settingsForExtension('denied.ext'); assert.strictEqual(deniedSettings.setAll({{flag: true}}).ok, false); assert.strictEqual(store.has('hermes.ext.settings.denied.ext'), false); @@ -92,6 +87,19 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): const deniedStorage = window.HermesExtensionSettings.storageForExtension('denied.ext'); assert.strictEqual(deniedStorage.set('note', 'blocked'), false); assert.strictEqual(store.has('hermes.ext.storage.denied.ext'), false); + + window.HermesExtensionSettings.primeFromStatus({{ + extensions: [{{ + id: 'unknown.ext', + name: 'Unknown', + storage_owned: true, + settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], + }}] + }}); + + const unknownSettings = window.HermesExtensionSettings.settingsForExtension('unknown.ext'); + assert.strictEqual(unknownSettings.setAll({{flag: true}}).ok, false); + assert.strictEqual(store.has('hermes.ext.settings.unknown.ext'), false); """ ) _run_node(script) From 615b023eb0731993e7c3ad88f52de318e0511e8c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 17:59:36 -0400 Subject: [PATCH 4/8] fix(extensions): pin schemas to trusted bootstrap state (#5094) --- api/extensions.py | 6 +++-- static/extension_settings.js | 3 ++- tests/test_extension_hooks.py | 31 ++++++++++++++++++++++++ tests/test_extension_settings_runtime.py | 20 +++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index 04bec03ee..b4b12eda4 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -1575,9 +1575,11 @@ def inject_extension_tags(index_html: str) -> str: else: result = block + result - if script_tags: + if runtime_config["extensions"] or script_tags: body_marker = "" - block = runtime_tag + "\n" + "\n".join(script_tags) + "\n" + block = runtime_tag + "\n" + if script_tags: + block += "\n".join(script_tags) + "\n" if body_marker in result: result = result.replace(body_marker, block + body_marker, 1) else: diff --git a/static/extension_settings.js b/static/extension_settings.js index 1b269d6e7..c42af46c6 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -125,6 +125,7 @@ id:entry.id, name:entry.name, storage_owned:entry.storage_owned, + settings_schema:entry.settings_schema, }); } trustedSeeded=true; @@ -137,7 +138,7 @@ id:entry.id, name:entry.name, storage_owned:trusted.storage_owned===true, - settings_schema:trusted.storage_owned===true?entry.settings_schema:[], + settings_schema:trusted.storage_owned===true?trusted.settings_schema:[], }); } } diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index e595c35c1..3886f23ca 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -160,6 +160,37 @@ def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_ assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js") +def test_extension_settings_only_manifest_still_injects_runtime_config(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "manifest.json").write_text( + """ + { + "extensions": [ + { + "id": "settings-only", + "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("") + + assert "window.__HERMES_EXTENSION_CONFIG__" in injected + assert '"id":"settings-only"' in injected + assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("") + + def test_extension_route_remains_behind_webui_auth(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index abe764e7d..2502bd7c3 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -100,6 +100,26 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): const unknownSettings = window.HermesExtensionSettings.settingsForExtension('unknown.ext'); assert.strictEqual(unknownSettings.setAll({{flag: true}}).ok, false); assert.strictEqual(store.has('hermes.ext.settings.unknown.ext'), false); + + window.HermesExtensionSettings.primeFromStatus({{ + extensions: [{{ + id: 'demo.ext', + name: 'Demo', + storage_owned: true, + settings_schema: [{{key: 'evil', type: 'string', default: ''}}], + }}, {{ + id: 'denied.ext', + name: 'Denied', + storage_owned: false, + settings_schema: [{{key: 'flag', type: 'boolean', default: false}}], + }}] + }}); + + const reprobe = window.HermesExtensionSettings.settingsForExtension('demo.ext'); + assert.deepStrictEqual(reprobe.schema.map(field => field.key), ['flag', 'mode', 'count']); + assert.strictEqual(reprobe.set('evil', 'owned').ok, true); + assert.strictEqual(reprobe.get('evil'), undefined); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); """ ) _run_node(script) From 938f721b167e38cc394a84f8dbaac56af5f26262 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 18:07:52 -0400 Subject: [PATCH 5/8] fix(extensions): reload before editing untrusted installs (#5094) --- api/extensions.py | 13 +------------ static/extension_settings.js | 3 +++ static/panels.js | 3 +++ tests/test_extension_hooks.py | 6 ------ 4 files changed, 7 insertions(+), 18 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index b4b12eda4..06ac5d916 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -1115,18 +1115,7 @@ def get_extension_config() -> Dict[str, Any]: _EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None ), } - runtime_entries: List[Dict[str, object]] = [] - if manifest is not None: - extension_state = _manifest_extension_state(manifest, disabled_ids) - runtime_entries = [ - { - "id": entry["id"], - "name": entry["name"], - "storage_owned": entry["storage_owned"], - "settings_schema": entry["settings_schema"], - } - for entry in extension_state["extensions"] - ] + runtime_entries = _extension_runtime_entries(manifest, disabled_ids) if manifest is not None else [] if runtime_entries: config["extensions"] = runtime_entries return config diff --git a/static/extension_settings.js b/static/extension_settings.js index c42af46c6..05c638eeb 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -231,6 +231,9 @@ } return { extensionId:clean, + trusted:schemas.has(clean), + storageOwned:!!meta.storage_owned, + supported:supportsSettings(meta), schema, defaults:defaultsFor(schema), get values(){return current();}, diff --git a/static/panels.js b/static/panels.js index 38035f88b..7c399c8e5 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8206,6 +8206,9 @@ function _extensionSettingsControls(entry){ return '
No extension-owned browser storage permission.
'; } const settingsApi=window.HermesExtensionSettings&&id?window.HermesExtensionSettings.settingsForExtension(id):null; + if(!settingsApi||!settingsApi.trusted){ + return '
Reload WebUI after enabling or installing this extension to edit browser-local settings.
'; + } const values=settingsApi?settingsApi.values:{}; const fields=schema.length ? schema.map(field=>_extensionSettingsFieldHtml(field,values[field.key])).join('') diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index 3886f23ca..515b5442a 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -270,12 +270,6 @@ def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkey "storage_owned": False, "settings_schema": [], }, - { - "id": "disabled", - "name": "disabled", - "storage_owned": False, - "settings_schema": [], - }, ], } From 46d91a7c61e100ad5af32643ab995a7fcba59868 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 18:15:36 -0400 Subject: [PATCH 6/8] fix(extensions): render installed controls from trusted state (#5094) --- static/panels.js | 2 +- tests/test_extensions_settings_panel.py | 4 +++- tests/test_issue4746_extension_gallery.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 7c399c8e5..07f102aa1 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8200,7 +8200,6 @@ function _extensionSettingsFieldHtml(field,value){ function _extensionSettingsControls(entry){ const id=(entry&&entry.id)||''; - const schema=Array.isArray(entry&&entry.settings_schema)?entry.settings_schema:[]; const storageOwned=!!(entry&&entry.storage_owned); if(!storageOwned){ return '
No extension-owned browser storage permission.
'; @@ -8209,6 +8208,7 @@ function _extensionSettingsControls(entry){ if(!settingsApi||!settingsApi.trusted){ return '
Reload WebUI after enabling or installing this extension to edit browser-local settings.
'; } + const schema=Array.isArray(settingsApi&&settingsApi.schema)?settingsApi.schema:[]; const values=settingsApi?settingsApi.values:{}; const fields=schema.length ? schema.map(field=>_extensionSettingsFieldHtml(field,values[field.key])).join('') diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index fa23c1faf..bc2094a9e 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -230,14 +230,16 @@ def test_extensions_installed_settings_route_through_shared_accessor(): bind_block = _between("function _bindExtensionSettingsButtons", "async function loadExtensionsPanel") gallery_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons") - assert "entry&&entry.settings_schema" in settings_block assert "entry&&entry.storage_owned" in settings_block assert "window.HermesExtensionSettings.settingsForExtension(id)" in settings_block + assert "settingsApi&&settingsApi.schema" in settings_block + assert "settingsApi||!settingsApi.trusted" in settings_block assert "data-extension-settings-save" in settings_block assert "data-extension-settings-reset" in settings_block assert "data-extension-storage-clear" in settings_block assert "Browser-local extension settings" in settings_block assert "Do not store secrets here" in settings_block + assert "Reload WebUI after enabling or installing this extension to edit browser-local settings." in settings_block assert "_extensionSettingsControls(entry)" in installed_block assert "window.HermesExtensionSettings.settingsForExtension(id).reset()" in bind_block assert "window.HermesExtensionSettings.storageForExtension(id).clear()" in bind_block diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py index 5d2c2a2bf..ce0289832 100644 --- a/tests/test_issue4746_extension_gallery.py +++ b/tests/test_issue4746_extension_gallery.py @@ -133,6 +133,14 @@ def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_p "enabled": True, "script_urls": ["/extensions/my-ext/assets/app.js"], "stylesheet_urls": ["/extensions/my-ext/assets/app.css"], + "extensions": [ + { + "id": "my-ext", + "name": "My Extension", + "storage_owned": False, + "settings_schema": [], + } + ], } status = ext_mod.get_extension_status() assert status["manifest"]["status"] == "gallery_installed" @@ -203,6 +211,14 @@ def test_install_bootstraps_managed_default_root_without_env(monkeypatch, tmp_pa "enabled": True, "script_urls": ["/extensions/plug-ext/app.js"], "stylesheet_urls": [], + "extensions": [ + { + "id": "plug-ext", + "name": "Plug And Play", + "storage_owned": False, + "settings_schema": [], + } + ], } status = ext_mod.get_extension_status() assert status["enabled"] is True From aef8e524fd5d07b805e967832c0347c9a4399a62 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 18:25:47 -0400 Subject: [PATCH 7/8] fix(extensions): keep settings-only installs canonical (#5094) --- api/extensions.py | 5 +- static/extension_settings.js | 15 +++++- tests/test_extension_settings_runtime.py | 4 ++ tests/test_issue4746_extension_gallery.py | 56 +++++++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/api/extensions.py b/api/extensions.py index 06ac5d916..d1f3d6882 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -857,7 +857,10 @@ def _gallery_installed_runtime_manifest( for key in ("name", "enabled", "scripts", "stylesheets", "sidecar", "permissions", "settings_schema"): if key in manifest: top_entry[key] = manifest[key] - if any(key in top_entry for key in ("scripts", "stylesheets", "sidecar")): + if any( + key in top_entry + for key in ("scripts", "stylesheets", "sidecar", "permissions", "settings_schema") + ): entries.append(_copy_manifest_entry_with_asset_base(top_entry, asset_base)) for _source, _index, entry in _manifest_extension_entries(manifest): copied = _copy_manifest_entry_with_asset_base(entry, asset_base) diff --git a/static/extension_settings.js b/static/extension_settings.js index 05c638eeb..5299f66d9 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -210,6 +210,14 @@ return overrides; } + function readSettingsState(schema,key){ + const stored=safeRead(key); + const checked=validate(schema,stored); + const overrides=overridesFromValues(schema,checked.values); + if(JSON.stringify(stored)!==JSON.stringify(overrides)) safeWrite(key,overrides); + return {values:checked.values,overrides}; + } + function supportsSettings(meta){ return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length); } @@ -220,7 +228,10 @@ const schema=supportsSettings(meta)?meta.settings_schema:[]; const key=settingsKey(clean); function current(){ - return validate(schema,safeRead(key)).values; + return supportsSettings(meta)?readSettingsState(schema,key).values:validate(schema,safeRead(key)).values; + } + function currentOverrides(){ + return supportsSettings(meta)?readSettingsState(schema,key).overrides:safeRead(key); } function setAll(values){ if(!supportsSettings(meta)) return {ok:false,values:current(),errors:{extension:'unsupported'}}; @@ -237,7 +248,7 @@ schema, defaults:defaultsFor(schema), get values(){return current();}, - get overrides(){return safeRead(key);}, + get overrides(){return currentOverrides();}, get(name){return current()[name];}, validate(values){return validate(schema,values);}, set(name,value){ diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index 2502bd7c3..bae6086cd 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -68,6 +68,10 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.strictEqual(window.hermesExt.settings.forExtension('demo.ext').get('mode'), 'compact'); assert.deepStrictEqual(settings.setAll({{flag: true, mode: 'compact', count: 2}}).values, {{flag: true, mode: 'compact', count: 2}}); assert.deepStrictEqual(JSON.parse(store.get('hermes.ext.settings.demo.ext')), {{flag: true}}); + store.set('hermes.ext.settings.demo.ext', JSON.stringify({{flag: false, unknown: 'kept', bad: 'x'}})); + assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); + assert.deepStrictEqual(settings.overrides, {{}}); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); assert.strictEqual(settings.set('mode', 'invalid').ok, false); assert.deepStrictEqual(settings.reset(), {{flag: false, mode: 'compact', count: 2}}); assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py index ce0289832..2fe5cf789 100644 --- a/tests/test_issue4746_extension_gallery.py +++ b/tests/test_issue4746_extension_gallery.py @@ -148,6 +148,62 @@ def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_p assert status["extensions"][0]["id"] == "my-ext" +def test_gallery_installed_settings_only_manifest_becomes_runtime_entry(monkeypatch, tmp_path): + """Settings-only gallery installs still surface in status and runtime config.""" + ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False) + monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False) + import api.extensions as ext_mod + + files = { + "my-ext/manifest.json": json.dumps( + { + "name": "Settings Only", + "version": "1.0.0", + "permissions": {"storage": {"owned": True}}, + "settings_schema": [ + {"key": "flag", "type": "boolean", "default": True}, + ], + } + ), + } + zip_bytes = _make_zip(files) + sha = hashlib.sha256(zip_bytes).hexdigest() + + monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes) + + ext_mod.install_extension( + "my-ext", + "https://hermes-webui.github.io/exts/my-ext.zip", + sha, + ) + + assert ext_mod.get_extension_config() == { + "enabled": True, + "script_urls": [], + "stylesheet_urls": [], + "extensions": [ + { + "id": "my-ext", + "name": "Settings Only", + "storage_owned": True, + "settings_schema": [ + {"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True}, + ], + } + ], + } + status = ext_mod.get_extension_status() + assert status["manifest"]["status"] == "gallery_installed" + assert status["counts"]["manifest_extensions"] == 1 + assert status["extensions"][0]["id"] == "my-ext" + assert status["extensions"][0]["storage_owned"] is True + assert status["extensions"][0]["settings_schema"] == [ + {"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True}, + ] + + def test_install_bootstraps_managed_default_root_without_env(monkeypatch, tmp_path): """Plug-and-play (#4933): one-click install works with NO env configured. From 7153e8d78f0024defd5bae29d44450289c302d66 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 18:31:43 -0400 Subject: [PATCH 8/8] fix(extensions): prune malformed browser overrides (#5094) --- static/extension_settings.js | 20 +++++++++++++------- tests/test_extension_settings_runtime.py | 8 ++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/static/extension_settings.js b/static/extension_settings.js index 5299f66d9..4918cf72f 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -143,17 +143,23 @@ } } - function safeRead(key){ + function safeReadState(key){ try{ const raw=window.localStorage.getItem(key); - if(!raw) return {}; + if(!raw) return {value:{},malformed:false}; const parsed=JSON.parse(raw); - return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:{}; + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed) + ?{value:parsed,malformed:false} + :{value:{},malformed:true}; }catch(_e){ - return {}; + return {value:{},malformed:true}; } } + function safeRead(key){ + return safeReadState(key).value; + } + function safeWrite(key,value){ const keys=Object.keys(value||{}); try{ @@ -211,10 +217,10 @@ } function readSettingsState(schema,key){ - const stored=safeRead(key); - const checked=validate(schema,stored); + const stored=safeReadState(key); + const checked=validate(schema,stored.value); const overrides=overridesFromValues(schema,checked.values); - if(JSON.stringify(stored)!==JSON.stringify(overrides)) safeWrite(key,overrides); + if(stored.malformed||JSON.stringify(stored.value)!==JSON.stringify(overrides)) safeWrite(key,overrides); return {values:checked.values,overrides}; } diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index bae6086cd..307da3628 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -72,6 +72,14 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); assert.deepStrictEqual(settings.overrides, {{}}); assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); + store.set('hermes.ext.settings.demo.ext', 'not-json'); + assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); + assert.deepStrictEqual(settings.overrides, {{}}); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); + store.set('hermes.ext.settings.demo.ext', JSON.stringify(['bad'])); + assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); + assert.deepStrictEqual(settings.overrides, {{}}); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); assert.strictEqual(settings.set('mode', 'invalid').ok, false); assert.deepStrictEqual(settings.reset(), {{flag: false, mode: 'compact', count: 2}}); assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false);