diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index 93edb86b..f1e68b8c 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -67,21 +67,24 @@ class StateReconciliation: state_manager: PluginStateManager, config_manager, plugin_manager, - plugins_dir: Path + plugins_dir: Path, + store_manager=None ): """ Initialize reconciliation system. - + Args: state_manager: PluginStateManager instance config_manager: ConfigManager instance plugin_manager: PluginManager instance plugins_dir: Path to plugins directory + store_manager: Optional PluginStoreManager for auto-repair """ self.state_manager = state_manager self.config_manager = config_manager self.plugin_manager = plugin_manager self.plugins_dir = Path(plugins_dir) + self.store_manager = store_manager self.logger = get_logger(__name__) def reconcile_state(self) -> ReconciliationResult: @@ -160,18 +163,34 @@ class StateReconciliation: message=f"Reconciliation failed: {str(e)}" ) + # Top-level config keys that are NOT plugins + _SYSTEM_CONFIG_KEYS = frozenset({ + 'web_display_autostart', 'timezone', 'location', 'display', + 'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width', + 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', + 'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging', + 'dim_schedule', 'network', 'system', 'schedule', + }) + def _get_config_state(self) -> Dict[str, Dict[str, Any]]: """Get plugin state from config file.""" state = {} try: config = self.config_manager.load_config() for plugin_id, plugin_config in config.items(): - if isinstance(plugin_config, dict): - state[plugin_id] = { - 'enabled': plugin_config.get('enabled', False), - 'version': plugin_config.get('version'), - 'exists_in_config': True - } + if not isinstance(plugin_config, dict): + continue + if plugin_id in self._SYSTEM_CONFIG_KEYS: + continue + if 'enabled' not in plugin_config: + continue + if '.standalone-backup-' in plugin_id: + continue + state[plugin_id] = { + 'enabled': plugin_config.get('enabled', False), + 'version': plugin_config.get('version'), + 'exists_in_config': True + } except Exception as e: self.logger.warning(f"Error reading config state: {e}") return state @@ -263,14 +282,15 @@ class StateReconciliation: # Check: Plugin in config but not on disk if config.get('exists_in_config') and not disk.get('exists_on_disk'): + can_repair = self.store_manager is not None inconsistencies.append(Inconsistency( plugin_id=plugin_id, inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK, description=f"Plugin {plugin_id} in config but not on disk", - fix_action=FixAction.MANUAL_FIX_REQUIRED, + fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED, current_state={'exists_on_disk': False}, expected_state={'exists_on_disk': True}, - can_auto_fix=False + can_auto_fix=can_repair )) # Check: Enabled state mismatch @@ -303,6 +323,9 @@ class StateReconciliation: self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config") return True + elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK: + return self._auto_repair_missing_plugin(inconsistency.plugin_id) + elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH: # Sync enabled state from state manager to config expected_enabled = inconsistency.expected_state.get('enabled') @@ -317,6 +340,34 @@ class StateReconciliation: except Exception as e: self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True) return False - + + return False + + def _auto_repair_missing_plugin(self, plugin_id: str) -> bool: + """Attempt to reinstall a missing plugin from the store.""" + if not self.store_manager: + return False + + # Try the plugin_id as-is, then without 'ledmatrix-' prefix + candidates = [plugin_id] + if plugin_id.startswith('ledmatrix-'): + candidates.append(plugin_id[len('ledmatrix-'):]) + + for candidate_id in candidates: + try: + self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id) + result = self.store_manager.install_plugin(candidate_id) + if isinstance(result, dict): + success = result.get('success', False) + else: + success = bool(result) + + if success: + self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id) + return True + except Exception as e: + self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True) + + self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id) return False diff --git a/web_interface/app.py b/web_interface/app.py index 7b186833..67ab420c 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -651,12 +651,40 @@ def _initialize_health_monitor(): _health_monitor_initialized = True -# Initialize health monitor on first request (using before_request for compatibility) +_reconciliation_done = False + +def _run_startup_reconciliation(): + """Run state reconciliation on startup to auto-repair missing plugins.""" + global _reconciliation_done + if _reconciliation_done: + return + _reconciliation_done = True + + try: + from src.plugin_system.state_reconciliation import StateReconciliation + reconciler = StateReconciliation( + state_manager=plugin_state_manager, + config_manager=config_manager, + plugin_manager=plugin_manager, + plugins_dir=plugins_dir, + store_manager=plugin_store_manager + ) + result = reconciler.reconcile_state() + if result.inconsistencies_found: + print(f"[Reconciliation] {result.message}") + if result.inconsistencies_fixed: + plugin_manager.discover_plugins() + except Exception as e: + print(f"[Reconciliation] Error: {e}") + +# Initialize health monitor and run reconciliation on first request @app.before_request def check_health_monitor(): - """Ensure health monitor is initialized on first request.""" + """Ensure health monitor and reconciliation run on first request.""" if not _health_monitor_initialized: _initialize_health_monitor() + if not _reconciliation_done: + _run_startup_reconciliation() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index fc667590..f232caf0 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -33,6 +33,23 @@ from src.web_interface.secret_helpers import ( separate_secrets, ) +import re + +_SECRET_KEY_PATTERN = re.compile( + r'(api_key|api_secret|password|secret|token|auth_key|credential)', + re.IGNORECASE, +) + +def _conservative_mask_config(config): + """Mask string values whose keys look like secrets (no schema available).""" + result = dict(config) + for key, value in result.items(): + if isinstance(value, dict): + result[key] = _conservative_mask_config(value) + elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key): + result[key] = '' + return result + # Will be initialized when blueprint is registered config_manager = None plugin_manager = None @@ -2505,24 +2522,14 @@ def get_plugin_config(): } # Mask secret fields before returning to prevent exposing API keys - # Fail closed — if schema unavailable, refuse to return unmasked config schema_mgr = api_v3.schema_manager - if not schema_mgr: - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - f"Cannot safely return config for {plugin_id}: schema manager unavailable", - status_code=500 - ) + schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) if schema_mgr else None - schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) - if not schema_for_mask or 'properties' not in schema_for_mask: - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking", - status_code=500 - ) - - plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties']) + if schema_for_mask and 'properties' in schema_for_mask: + plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties']) + else: + logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id) + plugin_config = _conservative_mask_config(plugin_config) return success_response(data=plugin_config) except Exception as e: