Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ LLM_CACHE_MAX_ENTRIES=256
PLUGIN_TRUST_POLICY=if-present
PLUGIN_TRUSTED_KEYS={}

# Plugin execution budget (Phase 9, issue #408): seconds an event handler or
# lifecycle hook may run before it is abandoned (daemon thread), recorded as a
# bounded error report, and dispatch continues. 0 disables the bound.
PLUGIN_EXECUTION_TIMEOUT=5

# Plugin outbound network policy (Phase 8, issue #193). Plugin code must make
# requests through app.services.plugin_network; the guard is https-only, denies
# private/loopback/link-local/reserved targets by default, and only reaches
Expand Down
7 changes: 7 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ class Config:
# discoverable plugins.
PLUGIN_DISCOVERY_DIR = os.getenv("PLUGIN_DISCOVERY_DIR", str(BASE_DIR / "plugins"))

# Plugin execution budget (#408). Event handlers and lifecycle hooks run in
# a daemon thread and are abandoned once they exceed this many seconds, so
# one slow plugin cannot stall a request. The timeout is recorded as a
# bounded error report and dispatch continues with the remaining handlers.
# Set to 0 (or negative) to disable the bound for local debugging.
PLUGIN_EXECUTION_TIMEOUT = float(os.getenv("PLUGIN_EXECUTION_TIMEOUT", "5"))

# Plugin outbound network policy (#193). Every plugin-triggered request must
# pass ``app.services.plugin_network``: it is https-only, denies
# private/loopback/link-local/reserved targets by default, and only reaches
Expand Down
89 changes: 72 additions & 17 deletions app/plugins/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,21 @@
from app.services.events import emit_event
from app.services.permissions import require_workspace_capability, resolve_workspace, role_for
from app.services.plugin_audit import record_plugin_audit
from app.services.plugin_compat import compatibility_status
from app.services.plugin_compat import (
IncompatibleCompatibilityError,
InvalidCompatibilityError,
compatibility_status,
enforce_compatible,
)
from app.services.plugin_config import validate_plugin_config
from app.services.plugin_errors import list_workspace_error_reports
from app.services.plugin_ops import discover_plugins
from app.services.plugin_ops import (
PluginEntryPointMismatchError,
PluginNotFoundError,
PluginUpdateError,
discover_plugins,
update_plugin,
)
from app.services.plugins import ManifestValidationError, PluginError, PluginManifest


Expand Down Expand Up @@ -204,21 +215,10 @@ def api_install_plugin(workspace_id: int):

# Reject a plugin whose explicit PEP 440 compatibility range does not
# include the running application version (an absent field is unrestricted).
if manifest.compatibility is not None:
status = compatibility_status(manifest.compatibility)
if not status["compatible"]:
return (
jsonify(
{
"error": (
f"Plugin {manifest.id} requires application "
f"{manifest.compatibility}, but this app is "
f"{status['app_version']}."
)
}
),
400,
)
try:
enforce_compatible(manifest.compatibility, subject=f"Plugin {manifest.id}")
except (IncompatibleCompatibilityError, InvalidCompatibilityError) as exc:
return jsonify({"error": str(exc)}), 400

plugin = Plugin.query.filter_by(id=manifest.id).first()
if plugin is None:
Expand Down Expand Up @@ -262,6 +262,61 @@ def api_install_plugin(workspace_id: int):
return jsonify(_serialize(plugin, workspace_id)), 201


@bp.route("/api/workspaces/<int:workspace_id>/plugins/<plugin_id>/update", methods=["POST"])
@login_required
@require_workspace_capability("manage_plugins")
def api_update_plugin(workspace_id: int, plugin_id: str):
"""Update a registered plugin from a validated manifest (owner only).

The manifest is fully validated and identity-bound: its ``id`` must match
the plugin being updated and its ``entry_point`` must not change, so an
update can never point an installed plugin at different code. The declared
PEP 440 ``compatibility`` range is enforced before anything is written; an
update that excludes the running application version is refused with
``400`` and a message naming both the range and the current version. The
registered plugin (and every workspace installation) is left untouched.

Capabilities and per-workspace configuration are not modified by an update;
grants stay explicit and workspace-scoped.
"""
installation = _installation(plugin_id, workspace_id)
if installation is None:
return jsonify({"error": "Plugin is not installed in this workspace."}), 404

data = request.get_json(silent=True) or {}
manifest_data = data.get("manifest")
if not isinstance(manifest_data, dict):
return jsonify({"error": "A manifest object is required."}), 400

try:
manifest = PluginManifest.from_dict(
manifest_data,
trusted_publishers=current_app.config["PLUGIN_TRUSTED_KEYS"],
trust_policy=current_app.config["PLUGIN_TRUST_POLICY"],
)
except ManifestValidationError as exc:
return jsonify({"error": f"Invalid plugin manifest: {exc}"}), 400
except PluginError as exc:
return jsonify({"error": str(exc)}), 400

try:
plugin = update_plugin(plugin_id, manifest)
except (IncompatibleCompatibilityError, InvalidCompatibilityError) as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 400
except PluginEntryPointMismatchError as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 409
except PluginUpdateError as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 400
except PluginNotFoundError:
db.session.rollback()
return jsonify({"error": "Plugin not found."}), 404

return jsonify(_serialize(plugin, workspace_id))


@bp.route("/api/workspaces/<int:workspace_id>/plugins/<plugin_id>/enable", methods=["POST"])
@login_required
@require_workspace_capability("manage_plugins")
Expand Down
7 changes: 6 additions & 1 deletion app/services/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import partial
from typing import Any

from app.extensions import db
from app.models import Plugin, PluginInstallation, Project, User, Workspace
from app.services.capabilities import Capability, CapabilityStore
from app.services.permissions import role_for
from app.services.plugin_audit import record_plugin_audit
from app.services.plugin_execution import run_with_timeout

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -319,7 +321,10 @@ def dispatch(
)
continue
try:
handler(event=event)
run_with_timeout(
partial(handler, event=event),
operation=f"dispatch:{event.event_type}",
)
results["successful"] += 1
except Exception as e:
results["failed"] += 1
Expand Down
40 changes: 40 additions & 0 deletions app/services/plugin_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class InvalidCompatibilityError(ValueError):
"""Raised when a compatibility field is not a valid PEP 440 specifier."""


class IncompatibleCompatibilityError(ValueError):
"""Raised when a valid compatibility range excludes the running app."""


@lru_cache(maxsize=1)
def _pyproject_version() -> str | None:
"""Read the ``project.version`` from the repository ``pyproject.toml``."""
Expand Down Expand Up @@ -123,3 +127,39 @@ def compatibility_status(compatibility: str | None) -> dict[str, object]:
"valid": valid,
"compatible": plugin_compatible(compatibility) if valid else False,
}


def enforce_compatible(compatibility: str | None, *, subject: str = "Plugin") -> None:
"""Refuse a plugin whose declared range excludes the running app version.

``subject`` names the thing being checked (for example
``"Plugin stellar-tools v1.2.0"``) and appears verbatim in the error, which
always reports both the declared range and the running version so the
operator can act on it. An omitted/empty range is unrestricted and never
raises.

Raises:
InvalidCompatibilityError: If ``compatibility`` is not valid PEP 440.
IncompatibleCompatibilityError: If the range excludes this app version.
"""
status = compatibility_status(compatibility)
if not status["valid"]:
raise InvalidCompatibilityError(f"Invalid compatibility specifier: {compatibility!r}")
if status["compatible"]:
return
raise IncompatibleCompatibilityError(
f"{subject} requires application {status['compatibility']}, "
f"but this app is {status['app_version']}."
)


__all__ = [
"IncompatibleCompatibilityError",
"InvalidCompatibilityError",
"app_version",
"compatibility_status",
"enforce_compatible",
"is_valid_compatibility",
"parse_compatibility",
"plugin_compatible",
]
139 changes: 139 additions & 0 deletions app/services/plugin_execution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Bounded execution for plugin callbacks (issue #408).

A plugin handler or lifecycle hook is third-party code: it may block on I/O,
loop forever, or simply take far longer than a request can afford. This module
runs such callbacks in a short-lived daemon thread and stops waiting once the
configured budget expires, so one slow plugin cannot stall a request.

The budget is ``PLUGIN_EXECUTION_TIMEOUT`` (seconds, default
:data:`DEFAULT_TIMEOUT_SECONDS`). A value of ``0`` (or negative) disables the
bound for local debugging.

Limitations (Python cannot kill threads):

* A callback that exceeds the budget is **abandoned**, not interrupted. It is a
daemon thread, so it never blocks interpreter shutdown, but it may still be
running (and finish) in the background. Callbacks must therefore be
idempotent and must not be relied on to have stopped.
* The current Flask application context is copied into the worker thread when
one is active, so plugins can still use ``current_app``. The calling thread
blocks until the callback finishes or the budget expires.
"""

from __future__ import annotations

import logging
import threading
from collections.abc import Callable
from typing import Any

logger = logging.getLogger(__name__)

#: Fallback budget used when the application configuration is unavailable.
DEFAULT_TIMEOUT_SECONDS = 5.0

#: Upper bound applied to the operation label stored in error reports.
_MAX_OPERATION_CHARS = 160


class PluginTimeoutError(TimeoutError):
"""Raised when a plugin callback exceeds its execution budget."""

def __init__(self, operation: str, timeout: float) -> None:
self.operation = str(operation)[:_MAX_OPERATION_CHARS]
self.timeout = timeout
super().__init__(f"Plugin operation '{self.operation}' timed out after {timeout:g}s")


def configured_timeout() -> float:
"""Return the configured callback budget in seconds (``<= 0`` disables it)."""
value: Any = DEFAULT_TIMEOUT_SECONDS
try:
from flask import current_app, has_app_context

if has_app_context():
value = current_app.config.get("PLUGIN_EXECUTION_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)
except Exception: # pragma: no cover - Flask is always available in the app
logger.debug("Could not read the plugin execution timeout", exc_info=True)
try:
return float(value)
except (TypeError, ValueError): # pragma: no cover - defensive
return DEFAULT_TIMEOUT_SECONDS


def _preserve_app_context(func: Callable[[], Any]) -> Callable[[], Any]:
"""Return ``func`` wrapped so an active Flask app context is available."""
try:
from flask import copy_current_app_context, has_app_context

if not has_app_context():
return func
return copy_current_app_context(func)
except Exception: # pragma: no cover - context copying is best effort
return func


def run_with_timeout(
func: Callable[[], Any],
*,
operation: str,
timeout: float | None = None,
) -> Any:
"""Call ``func`` and raise :class:`PluginTimeoutError` if it overruns.

Args:
func: Zero-argument callable to execute.
operation: Short label (for example ``dispatch:project.created``) used
in the timeout error and the recorded error report.
timeout: Budget in seconds; defaults to :func:`configured_timeout`.
A value ``<= 0`` disables the bound and calls ``func`` directly.

Returns:
Whatever ``func`` returns.

Raises:
PluginTimeoutError: If ``func`` does not finish within the budget.
Exception: Any exception raised by ``func`` is re-raised unchanged.
"""
limit = configured_timeout() if timeout is None else float(timeout)
if limit <= 0:
return func()

call = _preserve_app_context(func)
outcome: dict[str, Any] = {}
finished = threading.Event()

def _target() -> None:
try:
outcome["result"] = call()
except BaseException as exc: # re-raised in the caller thread below
outcome["error"] = exc
finally:
finished.set()

worker = threading.Thread(
target=_target,
name=f"plugin-callback:{str(operation)[:_MAX_OPERATION_CHARS]}",
daemon=True,
)
worker.start()

if not finished.wait(limit):
logger.warning(
"Plugin operation %s exceeded its %.3fs budget; abandoning the callback",
operation,
limit,
)
raise PluginTimeoutError(operation, limit)

if "error" in outcome:
raise outcome["error"]
return outcome.get("result")


__all__ = [
"DEFAULT_TIMEOUT_SECONDS",
"PluginTimeoutError",
"configured_timeout",
"run_with_timeout",
]
Loading
Loading