fix(plugins): normalize legacy boolean settings before schema validation (#588)

The news plugin's schema turned global.dynamic_duration from a boolean
into an {enabled, min_duration_seconds, ...} object. Installs that have
not saved the news settings since still hold `true`, so every start
logged "Plugin news config does not match its schema (loading anyway):
Field 'global.dynamic_duration': Expected type object, got bool" and
flagged news degraded.

The settings form already reads such a boolean as {"enabled": <bool>}
(render_nested_section in plugin_config.html) and the next save writes
the object. The loader did not. It now applies the same rule before
merging schema defaults and validating, so the defaults fill in the rest
of the object and the plugin receives it in the new shape.

The rule lives in schema_manager.legacy_bool_as_object /
normalize_legacy_booleans. It applies at any depth of nested objects
but not inside arrays, matching the form, and only to a real bool under
an object-typed property with an `enabled` child. Every other mismatch
still warns. A parity test renders the template macro against the helper
so the two cannot drift.

Nothing is written to config.json at load: the normalization is in
memory, and the next save of the plugin's settings persists the object.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-15 18:09:23 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 9f2743471c
commit c200b5837d
5 changed files with 509 additions and 2 deletions
+20 -1
View File
@@ -22,7 +22,7 @@ from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import SchemaManager
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
@@ -351,6 +351,7 @@ class PluginManager:
config = {}
# Check if plugin has a config schema
schema = None
schema_path = self.schema_manager.get_schema_path(plugin_id)
if schema_path is None:
# Schema file doesn't exist
@@ -368,6 +369,24 @@ class PluginManager:
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
)
# A plugin that turned an on/off boolean into an {enabled, ...}
# object still finds the boolean in config.json until the user saves
# its settings form, which carries it over (plugin_config.html).
# Read it the same way here, before the defaults fill in the rest
# of the object and before schema validation, so the plugin doesn't
# start with a schema warning and a degraded flag. In memory only:
# config.json is written by saves, never by loading a plugin.
if schema:
upgraded: List[str] = []
config = normalize_legacy_booleans(config, schema, upgraded)
if upgraded:
self.logger.info(
"Plugin %s: reading legacy boolean setting %s as "
"{\"enabled\": ...}; saving the plugin's settings "
"stores the new shape",
plugin_id, ", ".join(upgraded),
)
# Merge config with schema defaults to ensure all defaults are applied
try:
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
+79
View File
@@ -14,6 +14,85 @@ import jsonschema
from jsonschema import Draft7Validator, ValidationError
def _renders_as_object(prop: Dict[str, Any]) -> bool:
"""``field_type == 'object'`` as ``plugin_config.html`` computes it.
The template takes a type list's *first* entry, so ``["object", "null"]``
is an object and ``["boolean", "object"]`` is a checkbox.
"""
field_type = prop.get('type')
if isinstance(field_type, list):
field_type = field_type[0] if field_type else None
return field_type == 'object'
def legacy_bool_as_object(value: Any, prop: Any) -> Any:
"""Read a boolean stored where the schema now has an ``{enabled, ...}`` object.
Plugins turn an on/off switch into a settings object (news'
``global.dynamic_duration: true`` became ``{enabled, min_duration_seconds,
...}``), but config.json keeps the boolean until the user next saves that
plugin's form. The boolean was the switch the object's ``enabled`` now
holds, so it becomes ``{"enabled": value}`` and schema defaults fill the rest.
Returns ``value`` itself when the rule does not apply: anything that is not
a real ``bool`` (``1``, ``"true"``, ``None``) or a schema property that is not
an object with an ``enabled`` child. Those stay as they are, so validation
still reports a genuine mismatch.
This is the rule ``render_nested_section`` in
``web_interface/templates/v3/partials/plugin_config.html`` applies when it
draws the form. ``test/test_legacy_boolean_config.py`` renders that macro
against this function, so change both together.
"""
if not isinstance(value, bool) or not isinstance(prop, dict):
return value
properties = prop.get('properties')
if (_renders_as_object(prop) and isinstance(properties, dict)
and 'enabled' in properties):
return {'enabled': value}
return value
def normalize_legacy_booleans(config: Any, schema: Any,
changed_paths: Optional[List[str]] = None,
_prefix: str = '') -> Any:
"""Apply :func:`legacy_bool_as_object` at every depth of a plugin config.
Walks the config along the schema's ``properties`` the way the settings
form does: into nested objects, not into array items (the form hands arrays
to widgets and never applies the rule there).
Never mutates ``config``. Returns the same object when nothing changed, and
otherwise copies only the dicts on the path to each upgraded value. When
``changed_paths`` is given, the dotted path of each upgraded value is
appended to it.
"""
if not isinstance(config, dict) or not isinstance(schema, dict):
return config
properties = schema.get('properties')
if not isinstance(properties, dict):
return config
result = config
for key, value in config.items():
prop = properties.get(key)
if not isinstance(prop, dict) or not _renders_as_object(prop):
continue
path = f"{_prefix}.{key}" if _prefix else key
new_value = legacy_bool_as_object(value, prop)
if new_value is not value:
if changed_paths is not None:
changed_paths.append(path)
elif isinstance(value, dict):
new_value = normalize_legacy_booleans(value, prop, changed_paths, path)
if new_value is not value:
if result is config:
result = dict(config)
result[key] = new_value
return result
class SchemaManager:
"""
Manages plugin configuration schemas with caching and validation.