fix(plugin-system): unload/update race, failed-load cleanup, limits validation, schema lookup, install rollback (#653)

* fix(plugin-system): unload/update race, failed-load module cleanup, limits validation, schema lookup, install rollback, op-queue dedupe

- unload_plugin takes the per-plugin lock (5s bounded) before cleanup(),
  and an update() that finishes after its plugin was unloaded no longer
  sets the state back to ENABLED.
- A load that fails after import drops plugin_<id> and its submodules
  and forgets its manager fonts, so a fixed plugin reloads new code.
- Resource limits are validated as non-negative numbers: 400 at
  POST /plugins/limits, bad cached records ignored with one warning.
  Route docstrings note health/metrics reset and limits only change the
  web process's view.
- SchemaManager.get_schema_path resolves each search dir via
  resolve_plugin_dir (manifest id, ledmatrix-<id>) before the literal
  paths; plugins/ still before plugin-repos/. Misses cached 30s and
  logged once at DEBUG.
- install_from_url sets an existing copy aside and restores it if the
  move fails, under the per-plugin reinstall lock.
- Operation queue refuses a second pending op for a plugin and trims
  _operations with history.
- get_vegas_render_width reads display_manager.width first.
- get_logger in store/schema/health/resource/saved_repositories;
  UTF-8 reads in store_manager and state_manager.
- Docs: update_interval precedence (manifest over config) stated where
  users are told to set it in config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): build the limits 400 message from the field name, not an exception

CodeQL flagged str(e) flowing into the response. invalid_limit_field()
returns the offending field without raising, and limits_from_dict uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 10:41:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0e9e2cabba
commit c00bf5e8e6
24 changed files with 869 additions and 49 deletions
+32 -15
View File
@@ -21,10 +21,10 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple, Set
import logging
from jsonschema import Draft7Validator, ValidationError
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions, get_plugin_dir_mode, install_requirements_file,
sudo_remove_directory,
@@ -79,7 +79,7 @@ class PluginStoreManager:
``config/uninstalled_plugins.json`` under the project root.
"""
self.plugins_dir = Path(plugins_dir)
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
self.registry_cache = None
self.registry_cache_time = None # Timestamp of when registry was cached
self.github_cache = {} # Cache for GitHub API responses
@@ -333,7 +333,7 @@ class PluginStoreManager:
try:
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
if config_path.exists():
with open(config_path, 'r') as f:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('github', {}).get('api_token', '').strip()
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN":
@@ -1528,7 +1528,7 @@ class PluginStoreManager:
'error': 'No manifest.json found in repository' + (f' at path: {plugin_path}' if plugin_path else '')
}
with open(manifest_path, 'r') as f:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
requested_id = plugin_id
@@ -1590,7 +1590,7 @@ class PluginStoreManager:
if 'entry_point' not in manifest:
manifest['entry_point'] = 'manager.py'
# Write updated manifest back to file
with open(manifest_path, 'w') as f:
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2)
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
@@ -1599,16 +1599,33 @@ class PluginStoreManager:
# manifest's id -- so it can differ from the manifest id, which
# discovery tolerates by reading the manifest.
final_path = self.plugins_dir / plugin_id
if final_path.exists():
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
if not self._safe_remove_directory(final_path):
return {
'success': False,
'error': f'Failed to remove existing plugin directory: {final_path}'
}
shutil.move(str(temp_dir), str(final_path))
temp_dir = None # Prevent cleanup since we moved it
# Set the existing copy aside rather than deleting it, and put it
# back if the move fails: deleting first left the user with no
# plugin at all whenever the move broke part-way. Under the
# per-plugin reinstall lock, as install_plugin() is, so two
# overlapping installs of one id can't interleave their renames.
with self._get_reinstall_lock(plugin_id):
backup_path = None
if final_path.exists():
self.logger.warning(f"Plugin {plugin_id} already exists, replacing existing copy")
backup_path = final_path.with_name(
f"{final_path.name}{BACKUP_MARKER}preinstall")
problem = self._set_aside(final_path, backup_path)
if problem:
return {
'success': False,
'error': f'Failed to replace existing plugin directory: {problem}'
}
try:
shutil.move(str(temp_dir), str(final_path))
except Exception:
if backup_path is not None:
self._restore_backup(plugin_id, final_path, backup_path, "Install")
raise
temp_dir = None # Prevent cleanup since we moved it
if backup_path is not None:
self._discard_backup(plugin_id, backup_path, "install")
# Install dependencies
self._install_dependencies(final_path)