fix(extensions): keep runtime settings gated on owned storage (#5094)

This commit is contained in:
Rod Boev 2026-06-28 17:44:02 -04:00
parent a76c794d6d
commit 07ed67a793
6 changed files with 73 additions and 30 deletions

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
@ -559,26 +560,32 @@ def _normalize_enum_options(options: object) -> Optional[List[Dict[str, str]]]:
normalized.append({"value": value, "label": label})
return normalized
_SETTINGS_DEFAULT_MISSING = object()
def _normalize_settings_default(field_type: str, raw_default: object, options: Optional[List[Dict[str, str]]] = None) -> Tuple[bool, object]:
if field_type == "boolean":
if raw_default is None:
if raw_default is _SETTINGS_DEFAULT_MISSING:
return True, False
return (True, raw_default) if isinstance(raw_default, bool) else (False, None)
if field_type == "string":
if raw_default is None:
if raw_default is _SETTINGS_DEFAULT_MISSING:
return True, ""
return (True, raw_default) if isinstance(raw_default, str) else (False, None)
if field_type == "number":
if raw_default is None:
if raw_default is _SETTINGS_DEFAULT_MISSING:
return True, 0
return (True, raw_default) if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) else (False, None)
return (
(True, raw_default)
if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) and math.isfinite(raw_default)
else (False, None)
)
if field_type == "integer":
if raw_default is None:
if raw_default is _SETTINGS_DEFAULT_MISSING:
return True, 0
return (True, raw_default) if isinstance(raw_default, int) and not isinstance(raw_default, bool) else (False, None)
if field_type == "enum" and options:
values = [option["value"] for option in options]
if raw_default is None:
if raw_default is _SETTINGS_DEFAULT_MISSING:
return True, values[0]
return (True, raw_default) if isinstance(raw_default, str) and raw_default in values else (False, None)
return False, None
@ -615,7 +622,7 @@ def _sanitize_settings_schema(entry: Dict[str, object]) -> List[Dict[str, object
options = _normalize_enum_options(raw_field.get("options")) if field_type == "enum" else None
if field_type == "enum" and options is None:
continue
ok, default = _normalize_settings_default(field_type, raw_field.get("default"), options)
ok, default = _normalize_settings_default(field_type, raw_field.get("default", _SETTINGS_DEFAULT_MISSING), options)
if not ok:
continue
if key in seen_keys:
@ -1545,7 +1552,7 @@ def inject_extension_tags(index_html: str) -> str:
runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c")
runtime_tag = (
"<script>window.__HERMES_EXTENSION_CONFIG__={};"
"if(window.HermesExtensionSettings)window.HermesExtensionSettings.configure(window.__HERMES_EXTENSION_CONFIG__);"
"if(window.HermesExtensionSettings)window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__);"
"</script>"
).format(runtime_json)

View file

@ -97,19 +97,28 @@
return fields;
}
function configure(config){
const list=Array.isArray(config&&config.extensions)?config.extensions:[];
schemas.clear();
function normalizeSchemas(rawRows){
const list=Array.isArray(rawRows)?rawRows:[];
const entries=[];
for(const entry of list){
const id=extensionId(entry&&entry.id);
if(!id) continue;
schemas.set(id, {
const storageOwned=!!(entry&&entry.storage_owned);
entries.push({
id,
name:text(entry&&entry.name,id),
storage_owned:!!(entry&&entry.storage_owned),
settings_schema:normalizeSchema(entry&&entry.settings_schema),
storage_owned:storageOwned,
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
});
}
return entries;
}
function primeFromStatus(statusPayload){
schemas.clear();
for(const entry of normalizeSchemas(statusPayload&&statusPayload.extensions)){
schemas.set(entry.id,entry);
}
}
function safeRead(key){
@ -179,15 +188,20 @@
return overrides;
}
function supportsSettings(meta){
return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length);
}
function settingsForExtension(id){
const clean=extensionId(id);
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
const schema=meta.settings_schema||[];
const schema=supportsSettings(meta)?meta.settings_schema:[];
const key=settingsKey(clean);
function current(){
return validate(schema,safeRead(key)).values;
}
function setAll(values){
if(!supportsSettings(meta)) return {ok:false,values:current(),errors:{extension:'unsupported'}};
const checked=validate(schema,values);
if(!checked.ok) return checked;
const saved=safeWrite(key,overridesFromValues(schema,checked.values));
@ -209,10 +223,12 @@
},
setAll,
reset(){
if(!supportsSettings(meta)) return current();
safeWrite(key,{});
return current();
},
clear(){
if(!supportsSettings(meta)) return false;
safeWrite(key,{});
return true;
},
@ -220,24 +236,31 @@
}
function storageForExtension(id){
const key=storageKey(id);
const clean=extensionId(id);
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
const allowed=!!meta.storage_owned;
const key=storageKey(clean);
return {
getAll(){return safeRead(key);},
getAll(){return allowed?safeRead(key):{};},
get(name,defaultValue){
if(!allowed) return defaultValue;
const data=safeRead(key);
return Object.prototype.hasOwnProperty.call(data,name)?data[name]:defaultValue;
},
set(name,value){
if(!allowed) return false;
const data=safeRead(key);
data[name]=value;
return safeWrite(key,data);
},
remove(name){
if(!allowed) return false;
const data=safeRead(key);
delete data[name];
return safeWrite(key,data);
},
clear(){
if(!allowed) return false;
safeWrite(key,{});
return true;
},
@ -245,8 +268,8 @@
}
const api={
configure,
normalizeSchema,
normalizeSchemas,
primeFromStatus,
namespaceForExtension,
settingsForExtension,
storageForExtension,
@ -260,5 +283,5 @@
window.hermesExt.storage=window.hermesExt.storage||{};
window.hermesExt.settings.forExtension=settingsForExtension;
window.hermesExt.storage.forExtension=storageForExtension;
configure(window.__HERMES_EXTENSION_CONFIG__||{});
primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{});
})();

View file

@ -8170,7 +8170,7 @@ function _extensionEntryBadge(entry){
function _configureExtensionSettingsFromStatus(data){
if(!window.HermesExtensionSettings||!data||!Array.isArray(data.extensions)) return;
window.HermesExtensionSettings.configure({extensions:data.extensions});
window.HermesExtensionSettings.primeFromStatus({extensions:data.extensions});
}
function _extensionSettingsFieldHtml(field,value){

View file

@ -154,7 +154,7 @@ def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_
injected = inject_extension_tags("<html><head></head><body></body></html>")
assert "window.__HERMES_EXTENSION_CONFIG__" in injected
assert "window.HermesExtensionSettings.configure(window.__HERMES_EXTENSION_CONFIG__)" in injected
assert "window.HermesExtensionSettings.primeFromStatus(window.__HERMES_EXTENSION_CONFIG__)" in injected
assert '"storage_owned":true' in injected
assert '"settings_schema":[{"key":"flag","type":"boolean"' in injected
assert injected.index("window.__HERMES_EXTENSION_CONFIG__") < injected.index("/extensions/settings-ok.js")

View file

@ -42,7 +42,7 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears():
}};
eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8'));
window.HermesExtensionSettings.configure({{
window.HermesExtensionSettings.primeFromStatus({{
extensions: [{{
id: 'demo.ext',
name: 'Demo',
@ -75,6 +75,23 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears():
assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false);
assert.strictEqual(storage.clear(), true);
assert.strictEqual(store.has('hermes.ext.storage.demo.ext'), false);
window.HermesExtensionSettings.primeFromStatus({{
extensions: [{{
id: 'denied.ext',
name: 'Denied',
storage_owned: false,
settings_schema: [{{key: 'flag', type: 'boolean', default: false}}],
}}]
}});
const deniedSettings = window.HermesExtensionSettings.settingsForExtension('denied.ext');
assert.strictEqual(deniedSettings.setAll({{flag: true}}).ok, false);
assert.strictEqual(store.has('hermes.ext.settings.denied.ext'), false);
const deniedStorage = window.HermesExtensionSettings.storageForExtension('denied.ext');
assert.strictEqual(deniedStorage.set('note', 'blocked'), false);
assert.strictEqual(store.has('hermes.ext.storage.denied.ext'), false);
"""
)
_run_node(script)

View file

@ -223,6 +223,8 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p
{"key": "bad_enum", "type": "enum", "options": [{"label": "No value"}]},
{"key": "flag", "type": "boolean", "default": False},
{"key": "bad_default", "type": "integer", "default": 1.2},
{"key": "null_flag", "type": "boolean", "default": None},
{"key": "null_name", "type": "string", "default": None},
],
},
{
@ -243,13 +245,7 @@ def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_p
status = get_extension_status()
by_id = {entry["id"]: entry for entry in status["extensions"]}
assert by_id["settings-ok"]["storage_owned"] is True
assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == [
"flag",
"name",
"ratio",
"count",
"mode",
]
assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == ["flag", "name", "ratio", "count", "mode"]
assert by_id["settings-ok"]["settings_schema"][0]["default"] is True
assert by_id["settings-ok"]["settings_schema"][4]["options"] == [
{"value": "compact", "label": "Compact"},