merge #5155 extension settings surface

This commit is contained in:
nesquena-hermes
2026-06-28 23:07:00 +00:00
11 changed files with 1135 additions and 11 deletions
+179 -6
View File
@@ -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
@@ -72,8 +73,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 +524,122 @@ 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
_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 _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 _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 _SETTINGS_DEFAULT_MISSING:
return True, 0
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 _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 _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
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", _SETTINGS_DEFAULT_MISSING), 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,10 +854,13 @@ 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")):
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)
@@ -836,6 +958,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 +969,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 +991,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 +1102,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 +1118,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 +1549,15 @@ def inject_extension_tags(index_html: str) -> str:
'<script src="{}" defer></script>'.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 = (
"<script>window.__HERMES_EXTENSION_CONFIG__={};"
"if(window.HermesExtensionSettings)window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__);"
"</script>"
).format(runtime_json)
if stylesheet_tags:
head_marker = "</head>"
@@ -1396,9 +1567,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 = "</body>"
block = "\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:
+64
View File
@@ -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.
+329
View File
@@ -0,0 +1,329 @@
(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();
const trustedExtensions=new Map();
let trustedSeeded=false;
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 normalizeSchemas(rawRows){
const list=Array.isArray(rawRows)?rawRows:[];
const entries=[];
for(const entry of list){
const id=extensionId(entry&&entry.id);
if(!id) continue;
const storageOwned=!!(entry&&entry.storage_owned);
entries.push({
id,
name:text(entry&&entry.name,id),
storage_owned:storageOwned,
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
});
}
return entries;
}
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,
settings_schema:entry.settings_schema,
});
}
trustedSeeded=true;
}
schemas.clear();
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?trusted.settings_schema:[],
});
}
}
function safeReadState(key){
try{
const raw=window.localStorage.getItem(key);
if(!raw) return {value:{},malformed:false};
const parsed=JSON.parse(raw);
return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)
?{value:parsed,malformed:false}
:{value:{},malformed:true};
}catch(_e){
return {value:{},malformed:true};
}
}
function safeRead(key){
return safeReadState(key).value;
}
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 readSettingsState(schema,key){
const stored=safeReadState(key);
const checked=validate(schema,stored.value);
const overrides=overridesFromValues(schema,checked.values);
if(stored.malformed||JSON.stringify(stored.value)!==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);
}
function settingsForExtension(id){
const clean=extensionId(id);
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
const schema=supportsSettings(meta)?meta.settings_schema:[];
const key=settingsKey(clean);
function current(){
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'}};
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,
trusted:schemas.has(clean),
storageOwned:!!meta.storage_owned,
supported:supportsSettings(meta),
schema,
defaults:defaultsFor(schema),
get values(){return current();},
get overrides(){return currentOverrides();},
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(){
if(!supportsSettings(meta)) return current();
safeWrite(key,{});
return current();
},
clear(){
if(!supportsSettings(meta)) return false;
safeWrite(key,{});
return true;
},
};
}
function storageForExtension(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 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;
},
};
}
const api={
normalizeSchemas,
primeFromStatus,
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;
primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{});
})();
+2 -1
View File
@@ -1425,7 +1425,7 @@
</div>
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
<label>Trust model</label>
<div>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.</div>
<div>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.</div>
</div>
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
<div class="extensions-loading">Loading extension diagnostics…</div>
@@ -1655,6 +1655,7 @@
<script src="static/sessions.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/commands.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/messages.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/extension_settings.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/panels.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/onboarding.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
+144 -4
View File
@@ -8168,6 +8168,67 @@ function _extensionEntryBadge(entry){
return `<span class="extension-status-badge ${cls}">${esc(_extensionEntryStatusLabel(entry))}</span>`;
}
function _configureExtensionSettingsFromStatus(data){
if(!window.HermesExtensionSettings||!data||!Array.isArray(data.extensions)) return;
window.HermesExtensionSettings.primeFromStatus({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=`<label class="extension-setting-check"><input type="checkbox" ${dataAttrs}${value?' checked':''}> <span>${esc(label)}</span></label>`;
}else if(type==='number'||type==='integer'){
const step=type==='integer'?'1':'any';
control=`<label><span>${esc(label)}</span><input type="number" step="${step}" ${dataAttrs} value="${esc(String(value??''))}"></label>`;
}else if(type==='enum'){
const options=Array.isArray(field.options)?field.options:[];
control=`<label><span>${esc(label)}</span><select ${dataAttrs}>${options.map(option=>{
const optionValue=String(option&&option.value||'');
const optionLabel=String(option&&option.label||optionValue);
return `<option value="${esc(optionValue)}"${optionValue===value?' selected':''}>${esc(optionLabel)}</option>`;
}).join('')}</select></label>`;
}else{
control=`<label><span>${esc(label)}</span><input type="text" ${dataAttrs} value="${esc(String(value??''))}"></label>`;
}
return `<div class="extension-setting-field">${control}${desc?`<div class="extension-setting-desc">${esc(desc)}</div>`:''}</div>`;
}
function _extensionSettingsControls(entry){
const id=(entry&&entry.id)||'';
const storageOwned=!!(entry&&entry.storage_owned);
if(!storageOwned){
return '<div class="extension-settings-empty">No extension-owned browser storage permission.</div>';
}
const settingsApi=window.HermesExtensionSettings&&id?window.HermesExtensionSettings.settingsForExtension(id):null;
if(!settingsApi||!settingsApi.trusted){
return '<div class="extension-settings-empty">Reload WebUI after enabling or installing this extension to edit browser-local settings.</div>';
}
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('')
: '<div class="extension-settings-empty">No configurable settings declared.</div>';
return `<div class="extension-settings-box">
<div class="extension-settings-head">
<div>
<div class="extension-settings-title">Browser-local extension settings</div>
<div class="extension-settings-note">Settings and extension-owned storage stay in this browser. Do not store secrets here.</div>
</div>
</div>
<div class="extension-settings-fields">${fields}</div>
<div class="extension-settings-actions">
<button class="sm-btn" type="button" data-extension-settings-save="${esc(id)}"${schema.length?'':' disabled aria-disabled="true"'}>Save settings</button>
<button class="sm-btn" type="button" data-extension-settings-reset="${esc(id)}"${schema.length?'':' disabled aria-disabled="true"'}>Reset settings</button>
<button class="sm-btn" type="button" data-extension-storage-clear="${esc(id)}">Clear extension storage</button>
</div>
</div>`;
}
function _extensionInstalledList(extensions,extensionDirConfigured){
const list=Array.isArray(extensions)?extensions:[];
if(!list.length){
@@ -8192,6 +8253,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
${_extensionEntryBadge(entry)}
</div>
<div class="extension-installed-meta"><code>${esc(id)}</code><span>${esc(note)}</span></div>
${_extensionSettingsControls(entry)}
</div>
<button class="sm-btn extension-toggle-btn" type="button" data-extension-toggle-id="${esc(id)}" data-extension-next-enabled="${nextEnabled}"${disabledAttr}>${esc(buttonText)}</button>
</div>`;
@@ -8374,6 +8436,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 +8517,7 @@ function _renderExtensionsPanel(data,seq){
</div>
`;
_bindExtensionToggleButtons(target);
_bindExtensionSettingsButtons(target);
_monitorExtensionSidecars(sidecars,seq);
}
@@ -8483,6 +8547,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 +8835,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 +8845,14 @@ function _renderExtensionsGallery(entries,statusData){
}
if(!Array.isArray(entries)||entries.length===0){
if(galleryEl) galleryEl.innerHTML='<div class="extensions-empty">No extensions found in the registry.</div>';
if(installedEl) installedEl.innerHTML='<div class="extensions-empty">No extensions installed from the gallery.</div>';
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 +8891,13 @@ function _renderExtensionsGallery(entries,statusData){
<div class="extension-gallery-actions">${actionBtn}</div>
</div>`;
galleryCards.push(card);
if(isInstalled) installedCards.push(card);
}
if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'<div class="extensions-empty">No extensions found.</div>';
if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'<div class="extensions-empty">No extensions installed from the gallery.</div>';
if(installedEl){
installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured));
_bindExtensionToggleButtons(installedEl);
_bindExtensionSettingsButtons(installedEl);
}
_bindExtensionGalleryButtons(entries);
}
+10
View File
@@ -5109,6 +5109,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;}
+85
View File
@@ -126,6 +126,71 @@ def test_index_html_injection_escapes_urls_and_preserves_disabled_default(tmp_pa
assert injected.index("/extensions/app.js") < injected.index("</body>")
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("<html><head></head><body></body></html>")
assert "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")
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("<html><head></head><body></body></html>")
assert "window.__HERMES_EXTENSION_CONFIG__" in injected
assert '"id":"settings-only"' in injected
assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("</body>")
def test_extension_route_remains_behind_webui_auth(monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
@@ -198,6 +263,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 +304,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 +618,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):
+137
View File
@@ -0,0 +1,137 @@
"""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 = {{
__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)); }},
removeItem(key) {{ store.delete(key); }}
}}
}};
eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8'));
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}});
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);
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);
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);
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);
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);
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)
+70
View File
@@ -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,72 @@ 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},
{"key": "null_flag", "type": "boolean", "default": None},
{"key": "null_name", "type": "string", "default": None},
],
},
{
"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
):
+43
View File
@@ -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,32 @@ 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.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
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 +329,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 +422,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
+72
View File
@@ -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"
@@ -140,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.
@@ -203,6 +267,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