mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
merge #5155 extension settings surface
This commit is contained in:
+179
-6
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user