Skip to content

Add plugin system (backend + frontend)#38

Open
jhd3197 wants to merge 6 commits intomainfrom
dev
Open

Add plugin system (backend + frontend)#38
jhd3197 wants to merge 6 commits intomainfrom
dev

Conversation

@jhd3197
Copy link
Copy Markdown
Owner

@jhd3197 jhd3197 commented Mar 28, 2026

Introduce a plugin installation and management system.

Backend: add InstalledPlugin model, plugin_service with download/install/enable/disable/uninstall, dynamic blueprint registration and frontend manifest generation, and plugins API endpoints (list/get/install/enable/disable/uninstall). Register plugins blueprint and attempt to hot-load installed plugin blueprints at app startup.

Frontend: add PluginLoader component (Vite import.meta.glob) to render plugin widgets, extend Marketplace UI to install/manage plugins, add API client methods for plugins, and update styles for the marketplace plugin UI. This enables installing plugins from GitHub/repos/zips and managing them from the app.

jhd3197 and others added 2 commits March 28, 2026 15:58
Introduce a plugin installation and management system.

Backend: add InstalledPlugin model, plugin_service with download/install/enable/disable/uninstall, dynamic blueprint registration and frontend manifest generation, and plugins API endpoints (list/get/install/enable/disable/uninstall). Register plugins blueprint and attempt to hot-load installed plugin blueprints at app startup.

Frontend: add PluginLoader component (Vite import.meta.glob) to render plugin widgets, extend Marketplace UI to install/manage plugins, add API client methods for plugins, and update styles for the marketplace plugin UI. This enables installing plugins from GitHub/repos/zips and managing them from the app.
Copilot AI review requested due to automatic review settings March 28, 2026 19:58
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an install/manage plugin system spanning backend (install, persist, dynamically register blueprints) and frontend (plugin UI + loader + API client), integrating plugin management into the existing Marketplace flow.

Changes:

  • Backend: introduce InstalledPlugin model, plugin download/install/enable/disable/uninstall service, and /api/v1/plugins endpoints; attempt to load active plugin blueprints at startup.
  • Frontend: add plugins API client methods, Marketplace “Plugins” tab with install/manage UI, and a PluginLoader to render plugin UI components.
  • Styling/version: extend Marketplace styles for the plugins UI and bump VERSION to 1.4.14.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
frontend/src/styles/pages/_marketplace.scss Adds styles for the new Marketplace “Plugins” install/manage section.
frontend/src/services/api/plugins.js Introduces frontend API methods for plugin management endpoints.
frontend/src/services/api/index.js Registers the new plugin API methods into the ApiService.
frontend/src/plugins/PluginLoader.jsx Adds Vite glob-based discovery and rendering of plugin UI components.
frontend/src/pages/Marketplace.jsx Adds “Plugins” tab UI and actions to install/enable/disable/uninstall plugins.
frontend/src/layouts/DashboardLayout.jsx Renders PluginLoader in the main dashboard layout.
backend/app/services/plugin_service.py Implements plugin download, zip extraction, (optional) pip deps install, and blueprint registration + frontend manifest generation.
backend/app/models/plugin.py Adds InstalledPlugin model and serialization.
backend/app/api/plugins.py Adds plugin management API endpoints.
backend/app/init.py Registers the plugins blueprint and loads plugin blueprints at startup.
VERSION Bumps app version.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 8 to +12
const toast = useToast();
const { user } = useAuth();
const [extensions, setExtensions] = useState([]);
const [myExtensions, setMyExtensions] = useState([]);
const [plugins, setPlugins] = useState([]);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user is destructured from useAuth() but never used, which can trigger lint/build failures. Either remove it or use it to gate plugin install/enable/disable UI to admins (since the backend endpoints return 403 for non-admins).

Copilot uses AI. Check for mistakes.
Comment on lines +228 to +242
if rel_path.startswith('backend/'):
has_backend = True
out_path = os.path.join(backend_dest, rel_path[len('backend/'):])
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())

elif rel_path.startswith('frontend/'):
has_frontend = True
out_path = os.path.join(frontend_dest, rel_path[len('frontend/'):])
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())

elif rel_path == 'requirements.txt':
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zip extraction is vulnerable to path traversal (Zip Slip): rel_path comes from the archive and can contain ../ or absolute paths, allowing writes outside backend_dest/frontend_dest. Validate/sanitize each member path (e.g., reject absolute paths and .. segments, and ensure the resolved output path stays within the destination root) before writing.

Suggested change
if rel_path.startswith('backend/'):
has_backend = True
out_path = os.path.join(backend_dest, rel_path[len('backend/'):])
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif rel_path.startswith('frontend/'):
has_frontend = True
out_path = os.path.join(frontend_dest, rel_path[len('frontend/'):])
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif rel_path == 'requirements.txt':
# Basic Zip Slip protection: reject absolute paths and traversal segments
normalized_rel = rel_path.replace('\\', '/')
if os.path.isabs(normalized_rel) or '..' in normalized_rel.split('/'):
logger.warning(f'Skipping suspicious zip member path in plugin {slug}: {rel_path}')
continue
if normalized_rel.startswith('backend/'):
has_backend = True
rel_backend = normalized_rel[len('backend/'):]
out_path = os.path.normpath(os.path.join(backend_dest, rel_backend))
# Ensure the final path is still within backend_dest
backend_root = os.path.join(backend_dest, '')
if not out_path.startswith(backend_root):
logger.warning(f'Skipping backend file escaping plugin directory in {slug}: {rel_path}')
continue
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif normalized_rel.startswith('frontend/'):
has_frontend = True
rel_frontend = normalized_rel[len('frontend/'):]
out_path = os.path.normpath(os.path.join(frontend_dest, rel_frontend))
# Ensure the final path is still within frontend_dest
frontend_root = os.path.join(frontend_dest, '')
if not out_path.startswith(frontend_root):
logger.warning(f'Skipping frontend file escaping plugin directory in {slug}: {rel_path}')
continue
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif normalized_rel == 'requirements.txt':

Copilot uses AI. Check for mistakes.
Comment on lines +243 to +246
# Install Python dependencies
req_content = zf.read(member).decode('utf-8')
_install_requirements(req_content, slug)

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Installing requirements.txt from an untrusted plugin archive runs arbitrary code with the ServerKit backend’s privileges (pip can execute setup hooks / build steps). Consider disabling this by default and/or installing into an isolated per-plugin virtualenv/container with allowlisted packages, plus clear admin warnings and logging.

Suggested change
# Install Python dependencies
req_content = zf.read(member).decode('utf-8')
_install_requirements(req_content, slug)
# Handle Python dependencies from plugin requirements.txt
req_content = zf.read(member).decode('utf-8')
# By default, do NOT install requirements from untrusted plugins,
# as this may execute arbitrary code with backend privileges.
allow_untrusted = os.getenv(
"SERVERKIT_ENABLE_UNTRUSTED_PLUGIN_REQUIREMENTS", ""
).lower() in ("1", "true", "yes")
if not allow_untrusted:
logger.warning(
"Skipping installation of requirements.txt for plugin '%s' "
"because SERVERKIT_ENABLE_UNTRUSTED_PLUGIN_REQUIREMENTS is not enabled. "
"The requirements file will be extracted for manual review/installation.",
slug,
)
# Optionally persist requirements.txt into the backend directory
# so administrators can inspect and install dependencies manually.
os.makedirs(backend_dest, exist_ok=True)
req_out_path = os.path.join(backend_dest, "requirements.txt")
with open(req_out_path, "w", encoding="utf-8") as req_file:
req_file.write(req_content)
else:
logger.warning(
"Installing requirements.txt for plugin '%s' as "
"SERVERKIT_ENABLE_UNTRUSTED_PLUGIN_REQUIREMENTS is enabled. "
"This may execute arbitrary code during package installation.",
slug,
)
_install_requirements(req_content, slug)

Copilot uses AI. Check for mistakes.
Comment on lines +444 to +464
def enable_plugin(plugin_id):
"""Enable a disabled plugin."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_ACTIVE
plugin.error_message = None
db.session.commit()
_regenerate_frontend_manifest()
return plugin


def disable_plugin(plugin_id):
"""Disable a plugin without removing files."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_DISABLED
db.session.commit()
_regenerate_frontend_manifest()
return plugin
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disable_plugin()/enable_plugin() only flip DB status + regenerate the frontend manifest; they do not actually disable/enable already-registered Flask blueprints. Once a plugin blueprint is registered, its backend routes will remain reachable until process restart, which makes the “disabled” state misleading (and potentially unsafe). Consider enforcing status at request time (middleware) or requiring/recommending a restart for backend changes.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +15
class InstalledPlugin(db.Model):
"""Tracks plugins installed from external sources (zips/URLs)."""
__tablename__ = 'installed_plugins'

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
display_name = db.Column(db.String(256), nullable=False)
slug = db.Column(db.String(128), nullable=False, unique=True)
version = db.Column(db.String(32), nullable=False)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new SQLAlchemy model/table is introduced, but there is no Alembic migration for installed_plugins, and the model isn’t imported in app/models/__init__.py (used for model discovery). Add a migration revision and include InstalledPlugin in the models package exports so migrations and metadata include it.

Copilot uses AI. Check for mistakes.
plugin.status = InstalledPlugin.STATUS_INSTALLING
plugin.error_message = None
plugin.version = manifest['version']
plugin.source_url = url
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reinstalling an existing plugin record, only status, version, source_url, and manifest are updated. Fields like display_name, description, author, etc. can become stale if the new manifest changes them; update these fields from the manifest during reinstall to keep the DB consistent.

Suggested change
plugin.source_url = url
plugin.source_url = url
plugin.name = manifest['name']
plugin.display_name = manifest['display_name']
plugin.description = manifest.get('description', '')
plugin.author = manifest.get('author', '')
plugin.homepage = manifest.get('homepage', '')
plugin.repository = manifest.get('repository', '')
plugin.license = manifest.get('license', '')
plugin.category = manifest.get('category', 'utility')

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +82
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
return jsonify({'error': f'Installation failed: {e}'}), 500
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500-path returns the raw exception string in the response (Installation failed: {e}), which can leak internal details. Log the exception server-side and return a generic message (optionally with a correlation id) while storing the detailed error in InstalledPlugin.error_message.

Copilot uses AI. Check for mistakes.
* - A default component (the widget/UI to render)
* - Optionally a Provider component for context wrapping
*/
import React, { Suspense, useMemo } from 'react';
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspense is imported but never used, which will fail linting/build in many setups. Remove the unused import or actually wrap plugin rendering in a <Suspense> boundary if you plan to lazy-load plugin components.

Suggested change
import React, { Suspense, useMemo } from 'react';
import React, { useMemo } from 'react';

Copilot uses AI. Check for mistakes.
// Plugin management API methods

export async function getInstalledPlugins(status) {
const params = status ? `?status=${status}` : '';
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query param values should be URL-encoded. status is interpolated directly into the query string here; use encodeURIComponent(status) to avoid malformed URLs (and to match existing patterns like encodeURIComponent used elsewhere).

Suggested change
const params = status ? `?status=${status}` : '';
const params = status ? `?status=${encodeURIComponent(status)}` : '';

Copilot uses AI. Check for mistakes.
Comment thread backend/app/__init__.py
Comment on lines +283 to +289
# Load installed plugins (dynamic blueprints)
try:
from app.services.plugin_service import load_all_plugins
load_all_plugins(app)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f'Plugin loader: {e}')
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_all_plugins(app) is invoked before Alembic migrations run. On upgrade (when installed_plugins table doesn’t exist yet), this will fail and plugins won’t be loaded even after migrations are applied later in the same startup. Move plugin loading to after MigrationService.check_and_prepare(app) (or rerun after migrations) so plugin blueprints reliably register.

Copilot uses AI. Check for mistakes.
jhd3197 and others added 4 commits March 29, 2026 04:25
Introduce a comprehensive Style Guide (frontend/src/pages/StyleGuide.jsx) as a developer-only design system reference, plus new SCSS modules (_style-guide.scss, _alerts.scss, _tables.scss) and updates to existing component styles. Register the page route and title in App.jsx and add a DEV-only sidebar link (import.meta.env.DEV) in Sidebar.jsx; also import SIDEBAR_ITEMS from sidebarItems. These changes centralize UI patterns, components and utilities for easier visual QA and consistent styling without exposing the guide in production builds.
Add loading and size props to EmptyState (imports Spinner, shows spinner when loading, adjusts icon sizes) and add a large variant styling. Update StyleGuide to demonstrate loading, large/not-installed/unavailable states, and card contexts. Refactor SCSS across pages/components to use Sass theme variables ($bg-card, $border-subtle, $text-secondary, $accent-primary, etc.) replacing many var(...) usages and tweak related spacing/typography styles.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants