fix: auto-repair missing plugins and graceful config fallback

Plugins whose directories are missing (failed update, migration, etc.)
now get automatically reinstalled from the store on startup. The config
endpoint no longer returns a hard 500 when a schema is unavailable —
it falls back to conservative key-name-based masking so the settings
page stays functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ChuckBuilds
2026-03-25 14:49:36 -04:00
parent 81a022dbe8
commit 2619c0d893
3 changed files with 115 additions and 29 deletions

View File

@@ -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)

View File

@@ -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: