feedBack/plugins/__init__.py
Byron Gamatos 756588678b
fix(plugins): make a module plugin actually re-evaluate on reload (#879) (#897)
A plugin reload silently did nothing for scriptType:"module" plugins. ES modules are
evaluated ONCE PER URL PER DOCUMENT, so re-inserting a <script type="module"> whose src
the module map has already seen fires `load` without re-running the body — and the loader
then recorded the reload as applied. A no-op that reported success.

THE ISSUE UNDERSTATES IT. #879 says "upgrades are fine — a new version yields a new URL".
That is true of screen.js and FALSE of the plugin. I drove a real browser through
install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0), counting evaluations of src/main.js:

    ONE.

Not three, not two. The upgrade re-runs the one-line screen.js shim at its new ?v= URL;
the shim does `import './src/main.js'`; a relative specifier resolves against the base URL
WITH THE QUERY DROPPED; that is the same URL as before; the module map hands back the
already-evaluated v1.0.0 module. The plugin's own code never re-ran. Busting the entry
point cannot fix this, whatever token you hang off it.

So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. From there
'./src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.js — every relative import
inherits it, at every depth, for free. No import-specifier rewriting (which could never
see `import(expr)` anyway). Same browser drive after the fix: THREE evaluations.

Keyed on the plugin ID, not id@version: EVERY re-load of a module plugin needs a fresh
path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit
caching the R0 rails depend on is untouched. Classic-script plugins are not affected and
never take a /g/ path.

━━━ A PATH REWRITE, NOT TWO MIRRORED ROUTES ━━━

Codex caught this, and it was right. The token shifts the BASE URL, so EVERYTHING the
module graph resolves relatively moves with it — not only imports.
`new URL('../assets/worklet.js', import.meta.url)` from /api/plugins/x/g/1/src/main.js
resolves to /api/plugins/x/g/1/assets/worklet.js. Mirroring only screen.js and src/ would
have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and
would have broken again the next time someone added a plugin route.

So the /g/<token> segment is STRIPPED BEFORE ROUTING. Every plugin route, present and
future, works under the prefix with no extra wiring. The token is opaque and never joined
into a filesystem path, so containment still rests entirely on the same safe_join.

Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises
UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain
route serves fine. raw_path is informational and Starlette routes on scope["path"], so the
mutation is simply gone — and leaving raw_path as the client sent it is more truthful for
logs anyway.

TESTS. tests/js/plugin_module_rollback.test.js (5) + 8 in test_plugin_src_route.py:
identical bytes under the prefix, the whole graph one and two levels deep, ASSETS (the
Codex [P2]), every plugin route, non-ASCII filenames (the [P3]), an opaque token, and
containment asserted as PARITY with the un-prefixed route rather than a guessed 404 —
`../screen.js` legitimately 200s on both, because the URL normalises before routing.
All bite-tested: reverting the fix fails the rollback tests, disabling the rewrite fails
the asset tests.

Two harnesses re-anchored on `script.src = _pluginScriptUrl(` — the URL literal they keyed
on now lives in the helper, further down the file, so their slice ran off the end.

node 1045, pytest 2404, ESLint 0, Codex 0.

Closes #879

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:19:59 +02:00

2547 lines
129 KiB
Python

"""Plugin discovery and loading system."""
import hashlib
import importlib.util
import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import threading
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from safepath import safe_join
log = logging.getLogger("feedBack.plugins")
def _plugin_media_type(path: Path) -> str:
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
accept them; `mimetypes.guess_type` can miss these on a stripped platform
registry, so fall back explicitly (mirrors the assets/ route)."""
media_type = mimetypes.guess_type(path.name)[0]
if media_type is None and path.suffix == ".js":
return "application/javascript"
if media_type is None and path.suffix == ".css":
return "text/css"
return media_type or "application/octet-stream"
def _plugin_file_etag(path: Path) -> str | None:
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
This is what makes the live-edit loop work for module graphs: a conditional
GET revalidates and 304s unchanged files on refresh instead of re-downloading
the whole `src/` tree. Returns None if the file can't be stat'd."""
try:
st = path.stat()
except OSError:
return None
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
def _if_none_match(request: Request, etag: str) -> bool:
"""True when the client's If-None-Match already holds `etag`."""
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
# a direct compare is enough (comma-split tolerates a proxy concatenation).
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
"""Serve a plugin source/asset file with the live-edit cache contract:
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
ETag, and a bodyless 304 when the client's If-None-Match already matches.
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
itself, so the conditional handling has to live here."""
headers = {"Cache-Control": "no-cache"}
etag = _plugin_file_etag(path)
if etag:
headers["ETag"] = etag
if _if_none_match(request, etag):
return Response(status_code=304, headers=headers)
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
return FileResponse(path, media_type=media_type, headers=headers)
PLUGINS_DIR = Path(__file__).parent
# Holds only *ready* (loaded) plugins — those whose dependencies installed
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
# this list when it becomes ready. Kept ready-only because every consumer
# that imports LOADED_PLUGINS (settings export/import, diagnostics, the
# orphan-detection in lib/diagnostics_bundle.py) wants only usable plugins.
LOADED_PLUGINS = []
# Every discovered plugin that is NOT yet ready, keyed by plugin_id and held
# in discovery order. Each value is a lightweight, manifest-derived nav entry
# (no routes, no callables) carrying a `status` of "installing" or "failed"
# (plus `error` text when failed) so /api/plugins can render the nav slot
# immediately — disabled "installing…" / "failed" — while the background
# loader works through installs sequentially. A plugin leaves PENDING_PLUGINS
# only by GRADUATING into LOADED_PLUGINS (ready); a failed plugin stays here
# so it remains a visible, disabled nav entry until the next restart retries it.
PENDING_PLUGINS: dict = {}
# Guards all mutations of and snapshots from LOADED_PLUGINS and
# PENDING_PLUGINS so the background plugin-loader thread and the event-loop
# request handlers never race on either structure.
PLUGINS_LOCK = threading.RLock()
# Monotonic load-generation counter, bumped under PLUGINS_LOCK at the start of
# every load_plugins() pass. Each pass captures its generation and every
# registry mutation (the pending seed, _graduate, _mark_failed) re-checks it
# under the lock before touching LOADED_PLUGINS / PENDING_PLUGINS. This keeps a
# still-running loader from an EARLIER pass — e.g. a "reload plugins" action,
# FEEDBACK_SYNC_STARTUP hot-reload, or test teardown re-invoking load_plugins()
# while the first pass's background install thread is mid-flight — from
# repopulating or duplicating entries after a NEWER pass has already cleared the
# registries. Only the latest pass is allowed to publish.
_LOAD_GENERATION = 0
# Persistent pip install location (survives container restarts)
_PIP_TARGET = Path(os.environ.get("CONFIG_DIR", "/config")) / "pip_packages"
# --- Plugin enable/disable persistence (v3 Pedalboard footswitch) ---------
# Disabled plugins are persisted under CONFIG_DIR so the choice survives
# restarts; the loader skips them at startup (no routes/screen/nav/caps) while
# still surfacing them in /api/plugins as an "off" pedal you can switch back on.
# Guards the small JSON state file against the loader thread and request
# handlers racing on read/modify/write.
_PLUGIN_STATE_LOCK = threading.Lock()
# Plugins that may never be disabled — disabling them would brick the app or the
# capability-graph review surface. capability_inspector is the support surface
# for the capability pipelines; app_tour_* drive the first-run onboarding.
_ALWAYS_ENABLED_EXACT = frozenset({"capability_inspector"})
def _is_always_enabled(plugin_id: str) -> bool:
"""A small always-on set that cannot be disabled via the toggle endpoint."""
return plugin_id in _ALWAYS_ENABLED_EXACT or plugin_id.startswith("app_tour_")
def _suppress_capabilities_for_disabled(row: dict) -> dict:
"""A disabled plugin contributes NOTHING to the capability pipeline.
The frontend `static/capabilities.js` `registerParticipants()` registers any
/api/plugins entry that carries a `capabilities` declaration regardless of
status, so the backend empties the capability-pipelines.v1 fields here for
any plugin that is not actively contributing. That is:
* `enabled` is False (runtime-toggled-off, still in LOADED_PLUGINS), or
* status is "disabled" — a startup-skipped plugin that is NOT mounted.
Re-enabling such a plugin flips its `enabled` flag to True immediately
(to reflect intent) but it only actually mounts on the next restart, so
its handlers don't exist yet — surfacing its capabilities would register
a phantom pipeline participant. Keep them suppressed until it graduates.
Mutates and returns the row in place.
"""
if row.get("enabled", True) and row.get("status") != "disabled":
return row
row["capabilities"] = {}
row["standards"] = []
row["capability_validation_warnings"] = []
row["capability_unsupported_versions"] = []
row["compatibility_shims"] = []
return row
def _plugin_state_path() -> Path:
"""Location of the persisted enable/disable state. Resolved at call time (not
import time) so CONFIG_DIR can be overridden per-process (and per test)."""
return Path(os.environ.get("CONFIG_DIR", "/config")) / "plugin_state.json"
def _load_plugin_state() -> dict:
"""Read CONFIG_DIR/plugin_state.json → ``{"<id>": {"enabled": false}, ...}``.
Tolerant by construction: a missing file, unreadable file, malformed JSON,
or a non-object top level all fall back to ``{}`` with a warning. This MUST
never raise — it runs on the startup path and a corrupt state file must not
brick plugin loading (ADR: disabled is opt-in; absence means enabled).
"""
path = _plugin_state_path()
with _PLUGIN_STATE_LOCK:
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return {}
except UnicodeDecodeError as e:
# Non-UTF-8 bytes (encoding-corrupt file). Not an OSError, so it
# would otherwise escape — treat as corrupt and fall back to {}.
log.warning("Ignoring corrupt (non-UTF-8) plugin state %s: %s", path, e)
return {}
except OSError as e:
log.warning("Could not read plugin state %s: %s", path, e)
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
log.warning("Ignoring corrupt plugin state %s: %s", path, e)
return {}
if not isinstance(data, dict):
log.warning("Ignoring plugin state %s: expected an object, got %s", path, type(data).__name__)
return {}
return data
def _is_plugin_enabled(state: dict, plugin_id: str) -> bool:
"""Resolve a plugin's enabled flag from loaded state. Absent → enabled
(default true); a malformed per-plugin entry is treated as enabled too.
Always-on plugins are enabled regardless of what the file says."""
if _is_always_enabled(plugin_id):
return True
entry = state.get(plugin_id)
if not isinstance(entry, dict):
return True
value = entry.get("enabled")
# Only a real boolean is authoritative — anything else (missing, or junk
# like 0/""/[]/{} from a hand-edited or partially-corrupt file) falls back
# to enabled. This mirrors the endpoint's strict isinstance(bool) check so
# the only way to disable a plugin is a genuine ``{"enabled": false}``.
if not isinstance(value, bool):
return True
return value
def _persist_plugin_enabled(plugin_id: str, enabled: bool) -> None:
"""Persist a plugin's enabled flag, keeping the file minimal: only the
non-default (``enabled: false``) entries are stored; re-enabling drops the
key entirely. Written atomically (temp + os.replace) under the state lock."""
path = _plugin_state_path()
with _PLUGIN_STATE_LOCK:
# Re-read inside the lock so concurrent toggles compose instead of
# clobbering each other.
state: dict = {}
try:
existing = json.loads(path.read_text(encoding="utf-8"))
if isinstance(existing, dict):
state = existing
except FileNotFoundError:
state = {} # First write — nothing to preserve.
except (json.JSONDecodeError, UnicodeDecodeError):
# The existing file is corrupt (bad JSON or non-UTF-8 bytes) and is
# already ignored on load (treated as {}), so its disabled states
# aren't honored anyway — rewrite fresh to self-heal rather than fail
# the toggle.
state = {}
# Any other OSError (permission, IO error on an existing, readable-later
# file) is NOT swallowed: resetting to {} and writing would clobber every
# OTHER plugin's persisted disabled state. Let it propagate so the caller
# (set_plugin_enabled) returns a controlled 500 and the file is untouched.
if enabled:
state.pop(plugin_id, None)
else:
state[plugin_id] = {"enabled": False}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _safe_plugin_id_for_module_name(plugin_id: str) -> str:
"""Bijectively encode a plugin_id for safe use as part of a Python
module name.
Plugin ids are opaque manifest values that can take reverse-DNS
forms (`com.example.foo`) or contain other characters that
Python's import machinery interprets specially — most
importantly `.`, which it treats as a package boundary.
The encoding is **bijective** so distinct plugin_ids always map
to distinct encoded strings (otherwise two installed plugins
could share a cache-key prefix and reintroduce the cross-plugin
collision this PR is fixing). To make `_<hex>_` sequences in
the output ONLY appear as a result of intentional escapes, the
underscore is encoded first:
`_` → `_5f_` (hex of `_`)
`.` → `_2e_` (hex of `.`, applied after the `_` pass)
With this scheme:
`foo` → `foo`
`foo_bar` → `foo_5f_bar`
`foo.bar` → `foo_2e_bar`
`foo_2e_bar` → `foo_5f_2e_5f_bar` (distinct from `foo.bar`)
`com.example.x` → `com_2e_example_2e_x`
Spotted across multiple Copilot review rounds on PR #105.
"""
return plugin_id.replace("_", "_5f_").replace(".", "_2e_")
def _normalize_string_list(value) -> list[str]:
if not isinstance(value, list):
return []
# Strip surrounding whitespace and drop empty-after-strip entries so values
# like " capability-pipelines.v1 " match for version detection and a " "
# entry doesn't leak into API responses.
return [stripped for item in value if isinstance(item, str) and (stripped := item.strip())]
def _normalize_manifest_mapping(value) -> dict:
return value if isinstance(value, dict) else {}
def _normalize_manifest_sequence(value) -> list:
return value if isinstance(value, list) else []
_CAPABILITY_STANDARD = "capability-pipelines.v1"
_VALID_CAPABILITY_ROLES = {
"owner", "provider", "observer", "requester", "transformer", "handler",
"validator", "short-circuiter", "contributor",
}
_VALID_CAPABILITY_MODES = {"active", "optional", "legacy-shim", "disabled"}
_VALID_CAPABILITY_COMPATIBILITY = {"none", "shim-allowed", "degrade-noop", "required", "legacy-window-shim"}
_VALID_CAPABILITY_OWNERSHIP = {"exclusive-owner", "multi-provider", "observer-only", "requester-only", "privileged", "diagnostic-only"}
_VALID_CAPABILITY_KINDS = {"command", "provider-coordinator", "event", "diagnostic", "privileged"}
_VALID_CAPABILITY_SAFETY = {"safe", "privileged", "sensitive", "diagnostic-only"}
_VALID_CAPABILITY_SETTING_TYPES = {"toggle", "range", "select"}
def _validate_capability_setting_options(value, key):
"""Validate a `select` descriptor's options. Returns (clean, warning).
`clean` is None when absent; a warning string (and None clean) rejects the
whole options list. Mirrors the all-or-nothing strictness used for the
surrounding capability fields.
"""
if value is None:
return None, None
if not isinstance(value, list):
return None, f"settings entry '{key}' options must be a list"
clean = []
for opt in value:
if not isinstance(opt, dict):
return None, f"settings entry '{key}' options must be objects"
oid = opt.get("id")
if not isinstance(oid, str) or not oid.strip():
return None, f"settings entry '{key}' option requires a non-empty id"
descriptor = {"id": oid.strip()}
label = opt.get("label")
if isinstance(label, str) and label.strip():
descriptor["label"] = label.strip()
clean.append(descriptor)
return clean, None
def _validate_capability_settings(value):
"""Validate a capability's per-instance `settings` control descriptors.
Returns (clean_settings, warnings). `clean_settings` is None when the field
is absent, else a list of sanitized descriptor dicts. Any warning means the
surrounding capability declaration is dropped (the caller folds these into
its all-or-nothing `domain_warnings`), so manifest authors get a clear
signal rather than a silently half-applied control surface.
"""
if value is None:
return None, []
if not isinstance(value, list):
return None, ["settings must be a list of control descriptors"]
warnings = []
clean = []
seen_keys = set()
for entry in value:
if not isinstance(entry, dict):
warnings.append("each settings entry must be an object")
continue
key = entry.get("key")
ctype = entry.get("type")
if not isinstance(key, str) or not key.strip():
warnings.append("settings entry requires a non-empty string key")
continue
key = key.strip()
if key in seen_keys:
warnings.append(f"duplicate settings key '{key}'")
continue
if ctype not in _VALID_CAPABILITY_SETTING_TYPES:
warnings.append(f"settings entry '{key}' has unsupported type")
continue
descriptor = {"key": key, "type": ctype}
label = entry.get("label")
# Trim and treat empty-after-strip as absent (mirrors manifest
# normalization elsewhere) so hosts don't render blank labels.
if isinstance(label, str) and label.strip():
descriptor["label"] = label.strip()
if "default" in entry:
descriptor["default"] = entry["default"]
bad_number = False
for num_field in ("min", "max", "step"):
if num_field not in entry:
continue
num = entry[num_field]
# bool is an int subclass — exclude it so a stray `true` isn't a number.
# Declared-but-non-numeric is a warning (and drops the declaration via
# the all-or-nothing gate), not a silent drop: otherwise the author's
# control would behave differently than declared with no signal.
if isinstance(num, bool) or not isinstance(num, (int, float)):
warnings.append(f"settings entry '{key}' {num_field} must be a number")
bad_number = True
else:
descriptor[num_field] = num
if bad_number:
continue
if ctype == "select":
options, opt_warning = _validate_capability_setting_options(entry.get("options"), key)
if opt_warning:
warnings.append(opt_warning)
elif options is not None:
descriptor["options"] = options
clean.append(descriptor)
seen_keys.add(key)
return clean, warnings
def _coerce_capability_version(value):
"""Coerce a capability `version` to a finite number for the supported-version
check, or None when it isn't a recognizable number. The motivating case is a
numeric *string* like "1": the browser runtime does `Number(version)` and
treats "1" as version 1, so the backend must accept it too rather than drop
the declaration. A returned number != 1 is reported unsupported (matching the
runtime's `Number.isFinite(v) && v !== 1`); None defaults to compatible
(version 1), consistent with the runtime's `Number(version) || 1` fallback.
This handles ints, floats, and decimal numeric strings — the forms that
occur in real manifests. It does NOT mirror every JS `Number()` quirk
(hex/binary/octal literals, "" / null / false coercing to 0); those are not
valid manifest versions, and the runtime itself is inconsistent about them,
so they fall through to the compatible default. bool is rejected so
`True`/`False` aren't read as 1/0."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
try:
number = float(value)
except OverflowError:
# An absurdly large JSON integer can't convert to float; it's not a
# real version, so default to compatible rather than crash parsing.
return None
elif isinstance(value, str):
text = value.strip()
if not text:
return None
try:
number = float(text)
except (ValueError, OverflowError):
return None
else:
return None
# Reject NaN and infinities (a non-finite version is not a real version).
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def _capability_warnings(manifest: dict, plugin_id: str) -> tuple[dict, list[dict], list[dict]]:
raw_capabilities = manifest.get("capabilities")
warnings: list[dict] = []
unsupported: list[dict] = []
if raw_capabilities is None:
return {}, warnings, unsupported
if not isinstance(raw_capabilities, dict):
warnings.append({"field": "capabilities", "reason": "capabilities must be an object"})
return {}, warnings, unsupported
standards = _normalize_string_list(manifest.get("standards"))
unsupported_standards = [item for item in standards if item.startswith("capability-pipelines.") and item != _CAPABILITY_STANDARD]
sanitized: dict = {}
for domain, declaration in raw_capabilities.items():
domain_warnings: list[str] = []
if not isinstance(domain, str) or not domain:
warnings.append({"field": "capabilities", "reason": "capability domain names must be non-empty strings"})
continue
if not isinstance(declaration, dict):
warnings.append({"field": f"capabilities.{domain}", "reason": "capability declaration must be an object"})
continue
version = declaration.get("version", 1)
# Mirror the browser runtime's version handling: it does
# `Number(version)` and treats a declaration incompatible only when
# that is finite and != 1 (static/capabilities.js). So a numeric string
# like "1" coerces to a supported version, and an unparseable version
# falls back to 1 (compatible) rather than being dropped. Matching this
# avoids /api/plugins dropping declarations the runtime would accept.
numeric_version = _coerce_capability_version(version)
version_incompatible = numeric_version is not None and numeric_version != 1
if unsupported_standards or version_incompatible:
unsupported.append({
"plugin_id": plugin_id,
"domain": domain,
"standards": unsupported_standards or standards,
# Keep the raw value for diagnostics of truly unsupported versions.
"version": version,
"reason": "unsupported capability-pipelines version",
})
sanitized[domain] = {
"roles": [],
"commands": [],
"events": [],
"mode": "disabled",
"compatibility": "degrade-noop",
"ownership": "diagnostic-only",
"safety": "diagnostic-only",
"version": int(numeric_version) if numeric_version is not None and numeric_version == int(numeric_version) else 1,
"incompatible": True,
}
continue
roles = declaration.get("roles", [])
commands = declaration.get("commands", [])
operations = declaration.get("operations", [])
requests = declaration.get("requests", [])
observes = declaration.get("observes", [])
emits = declaration.get("emits", [])
events = declaration.get("events", [])
kind = declaration.get("kind", "")
mode = declaration.get("mode", "active")
compatibility = declaration.get("compatibility", "degrade-noop")
ownership = declaration.get("ownership", "exclusive-owner")
safety = declaration.get("safety", "safe")
if not isinstance(roles, list) or any(not isinstance(item, str) or item not in _VALID_CAPABILITY_ROLES for item in roles):
domain_warnings.append("roles must be known capability roles")
# Reject whitespace-only entries (not just empty strings): the browser
# runtime trims and drops them, so accepting them here would surface
# commands/operations/etc via /api/plugins that the runtime never sees.
if not isinstance(commands, list) or any(not isinstance(item, str) or not item.strip() for item in commands):
domain_warnings.append("commands must be a list of non-empty strings")
if not isinstance(operations, list) or any(not isinstance(item, str) or not item.strip() for item in operations):
domain_warnings.append("operations must be a list of non-empty strings")
if not isinstance(requests, list) or any(not isinstance(item, str) or not item.strip() for item in requests):
domain_warnings.append("requests must be a list of non-empty strings")
if not isinstance(observes, list) or any(not isinstance(item, str) or not item.strip() for item in observes):
domain_warnings.append("observes must be a list of non-empty strings")
if not isinstance(emits, list) or any(not isinstance(item, str) or not item.strip() for item in emits):
domain_warnings.append("emits must be a list of non-empty strings")
if not isinstance(events, list) or any(not isinstance(item, str) or not item.strip() for item in events):
domain_warnings.append("events must be a list of non-empty strings")
if kind and kind not in _VALID_CAPABILITY_KINDS:
domain_warnings.append("kind is not supported")
if mode not in _VALID_CAPABILITY_MODES:
domain_warnings.append("mode is not supported")
if compatibility not in _VALID_CAPABILITY_COMPATIBILITY:
domain_warnings.append("compatibility is not supported")
if ownership not in _VALID_CAPABILITY_OWNERSHIP:
domain_warnings.append("ownership is not supported")
if safety not in _VALID_CAPABILITY_SAFETY:
domain_warnings.append("safety is not supported")
order = declaration.get("order")
if order is not None and not isinstance(order, dict):
domain_warnings.append("order must be an object")
clean_settings, settings_warnings = _validate_capability_settings(declaration.get("settings"))
domain_warnings.extend(settings_warnings)
if domain_warnings:
warnings.append({"field": f"capabilities.{domain}", "reason": "; ".join(domain_warnings)})
continue
clean = {
"roles": roles,
# Store trimmed values so backend output matches the browser
# runtime (which trims). Validation above already guaranteed every
# entry is a non-empty-after-strip string.
"commands": _normalize_string_list(commands),
"operations": _normalize_string_list(operations),
"requests": _normalize_string_list(requests),
"observes": _normalize_string_list(observes),
"emits": _normalize_string_list(emits),
"events": _normalize_string_list(events),
"mode": mode,
"compatibility": compatibility,
"ownership": ownership,
"safety": safety,
"version": 1,
}
# Only emit `kind` when the manifest actually declared one. It defaults
# to "" above, but "" is not a valid value in the published manifest
# schema (kind is an enum), so emitting kind: "" would make /api/plugins
# and diagnostics violate the schema and confuse consumers.
if kind:
clean["kind"] = kind
if isinstance(order, dict):
clean["order"] = order
if isinstance(declaration.get("provider_policy"), dict):
clean["provider_policy"] = declaration["provider_policy"]
# Declarative per-instance control descriptors a consuming host renders
# generically (feedBack#849). Domain-agnostic: validated for any
# capability here and surfaced via /api/plugins; each domain defines how
# a value is applied (visualization is the first consumer).
if clean_settings:
clean["settings"] = clean_settings
# Preserve the human-readable text the runtime/Inspector display
# (static/capabilities.js reads declaration.description / .summary).
# Dropping these here would blank capability descriptions in
# /api/plugins and the diagnostics bundle even for valid manifests.
for _text_field in ("description", "summary"):
_value = declaration.get(_text_field)
if isinstance(_value, str) and _value:
clean[_text_field] = _value
sanitized[domain] = clean
return sanitized, warnings, unsupported
def _compatibility_shims_from_manifest(manifest: dict, plugin_id: str) -> list[dict]:
return []
def _normalize_ui_contributions(manifest: dict) -> dict:
declared = {
**_normalize_manifest_mapping(manifest.get("ui_contributions")),
**_normalize_manifest_mapping(manifest.get("ui")),
}
legacy = []
if manifest.get("nav"):
legacy.append({"region": "ui.navigation", "legacy_source": "nav"})
if manifest.get("screen"):
legacy.append({"region": "ui.plugin-screens", "legacy_source": "screen"})
if manifest.get("settings"):
legacy.append({"region": "settings", "legacy_source": "settings"})
if manifest.get("type") == "visualization":
legacy.append({"region": "visualization", "legacy_source": "type"})
return {"declared": declared, "legacy": legacy}
def _normalize_runtime_domains(manifest: dict) -> dict:
return {
**_normalize_manifest_mapping(manifest.get("runtime_domains")),
**_normalize_manifest_mapping(manifest.get("domains")),
}
def _load_plugin_sibling(plugin_id: str, plugin_dir: Path, name: str):
"""Load a sibling module from a plugin's directory under a namespaced
module name (`plugin_<plugin_id>.<name>`, with plugin_id
bijectively encoded by `_safe_plugin_id_for_module_name` —
`_` -> `_5f_`, `.` -> `_2e_`). Both single-file siblings
(`extractor.py`) and package-form siblings (`extractor/__init__.py`)
are supported; package form wins when both exist (matches CPython's
import precedence). Mirrors the routes-loading pattern in
`load_plugins()` and shares its `sys.modules` cache, so two plugins
that each ship `extractor.py` get distinct cached modules instead
of stomping each other through `sys.path`. See feedBack#33."""
if not isinstance(plugin_id, str) or not plugin_id:
raise ValueError(
f"load_sibling: plugin_id must be a non-empty string, got {plugin_id!r}"
)
if (
not isinstance(name, str)
or not name
or "/" in name
or "\\" in name
or "." in name
or name.endswith(".py")
):
# Reject path traversal, the redundant `.py` suffix, and any
# `.` (the separator between id and name in the cache key).
raise ValueError(
f"plugin {plugin_id!r}: load_sibling expects a bare module name, got {name!r}"
)
safe_plugin_id = _safe_plugin_id_for_module_name(plugin_id)
parent_name = f"plugin_{safe_plugin_id}"
module_name = f"{parent_name}.{name}"
# Pre-check that the sibling actually exists before we hand off
# to importlib.import_module — its ModuleNotFoundError is less
# specific than the message we want to surface (which lists both
# probed paths so a confused author sees "I checked here AND
# here").
file_path = plugin_dir / f"{name}.py"
pkg_init = plugin_dir / name / "__init__.py"
if not file_path.is_file() and not pkg_init.is_file():
raise ImportError(
f"plugin {plugin_id!r}: no sibling module {name!r} at "
f"{file_path} or {pkg_init}"
)
# Register a synthetic parent package so the standard import
# machinery can find this plugin's siblings via the parent's
# `__path__`. The parent points at the plugin's directory; this
# is what relative imports between siblings consult. It does NOT
# undermine the namespace isolation, because:
# • bare `import sibling` still goes through sys.path (the
# transition fallback for plugins that haven't migrated)
# • `import plugin_<id>.sibling` lands in the namespaced
# sys.modules entry — same key load_sibling produces
# `setdefault` is atomic under the GIL so two threads racing to
# create the parent can't overwrite each other's registration.
# Spotted by codex/Copilot reviews on PRs for feedBack#33.
import types
new_parent = types.ModuleType(parent_name)
new_parent.__path__ = [str(plugin_dir)]
sys.modules.setdefault(parent_name, new_parent)
# Delegate the actual load to importlib.import_module. It uses
# Python's per-module import lock, so concurrent callers — via
# load_sibling, relative imports inside another sibling
# (`from . import extractor`), or an explicit
# `importlib.import_module('plugin_<id>.<name>')` from anywhere
# — all serialize through the SAME lock. A rolled-our-own lock
# could only coordinate load_sibling callers; the standard lock
# plugs cross-API races where the half-initialized module would
# otherwise leak. Python's standard finder walks the parent's
# `__path__`, picks package over file when both exist (matching
# CPython precedence), exposes the child as an attribute on the
# parent post-load (`setattr(parent, name, child)`), and cleans
# up sys.modules on exec failure — all the things this helper
# used to do by hand. Spotted by Copilot review on PR #105
# round 5.
return importlib.import_module(module_name)
def _warn_on_module_collisions(plugin_specs):
"""Scan top-level importable modules across all plugins about to
be loaded. Print a warning for any module name shipped by 2+
plugins, since bare `import <name>` from those plugins will hit
the sys.path-based cache and cross-load (feedBack#33).
Both top-level `.py` files AND top-level packages (directories
containing `__init__.py`) are scanned — the same collision
pattern applies to either, e.g. one plugin's `extractor.py` vs
another plugin's `extractor/__init__.py` both produce a shared
`sys.modules['extractor']` entry. Spotted by codex review on
PR for feedBack#33.
`routes.py` itself is excluded because the loader already
namespaces it as `plugin_{id}_routes`. Top-level dunder files
(like a hypothetical bare `__main__.py`) are excluded too.
`plugin_specs` is a list of `(plugin_id, plugin_dir)` tuples for
plugins the loader has decided to load (post-dedup).
"""
# Map: module_name -> {plugin_id: set_of_kinds}.
# Using a per-plugin nested dict deduplicates the case where ONE
# plugin ships both `extractor.py` and `extractor/__init__.py`
# — that intra-plugin layout is supported by load_sibling
# (package form wins, matching CPython precedence) and shouldn't
# trip a cross-plugin collision warning. Spotted by codex review
# on PR for feedBack#33.
by_name: dict[str, dict[str, set[str]]] = {}
for plugin_id, plugin_dir in plugin_specs:
try:
for child in plugin_dir.iterdir():
module_name = None
kind = None
if child.is_file() and child.suffix == ".py":
if child.name == "routes.py" or child.name.startswith("__"):
continue
module_name = child.stem
kind = "module"
elif child.is_dir() and (child / "__init__.py").is_file():
if child.name.startswith("__"):
continue
module_name = child.name
kind = "package"
if module_name is None:
continue
by_name.setdefault(module_name, {}).setdefault(plugin_id, set()).add(kind)
except OSError:
# Unreadable plugin dir — the per-plugin load below will
# surface the error in a more useful place; don't warn here.
continue
for name, by_plugin in by_name.items():
# Count distinct plugin ids — only fire when MULTIPLE plugins
# ship the same module name. A single plugin shipping the
# name in multiple forms is fine.
if len(by_plugin) < 2:
continue
ids_quoted = ", ".join(f"'{pid}'" for pid in sorted(by_plugin))
# Aggregate kinds across all plugins to label the warning.
kinds = {k for kind_set in by_plugin.values() for k in kind_set}
kind_label = "module/package" if len(kinds) > 1 else next(iter(kinds))
log.warning(
"Module-name collision: %r (%s) is shipped by %d plugins (%s). "
"Bare `import %s` may load the wrong file. "
"Migrate to context['load_sibling']('%s') — see CLAUDE.md (feedBack#33).",
name, kind_label, len(by_plugin), ids_quoted, name, name,
)
def _is_safe_tour_manifest_filename(val) -> bool:
"""Return True only for non-empty relative tour manifest filenames.
The filename must not be absolute, must not contain backslashes, must
not contain any ``..`` path segment, and must not be a bare ``.`` (which
would resolve to the plugin directory itself) so ``has_tour`` only
advertises files that are eligible to be served by the route handler.
"""
if not isinstance(val, str) or not val:
return False
if "\\" in val or os.path.isabs(val):
return False
p = Path(val)
if ".." in p.parts:
return False
# Reject "." and any path whose final component is "." (e.g. "./").
return p.name != '.'
def _is_valid_tour_manifest(val) -> bool:
"""Return True only when the tour manifest field is a usable string
filename or a dict. A dict without a ``file`` key is valid — the route
handler defaults it to ``"tour.json"``. A dict that explicitly sets
``file`` must use a non-empty safe relative string (``file: null``,
``file: 1``, ``file: "../x.json"``, absolute paths, etc. are rejected).
Empty strings, non-string scalars (e.g. ``true``, ``1``), and dicts with
an explicitly invalid ``file`` value are treated as absent.
"""
if isinstance(val, str):
return _is_safe_tour_manifest_filename(val)
if isinstance(val, dict):
if "file" not in val:
return True # no file key → defaults to "tour.json" in the route
return _is_safe_tour_manifest_filename(val["file"])
return False
def _normalize_export_paths(settings_field, plugin_id: str) -> list[str]:
"""Validate and normalize a plugin's `settings.server_files` manifest
list into clean POSIX-style relpaths suitable for the settings
export/import bundle (feedBack#113).
Each entry must be a non-empty string with no absolute prefix and
no `..` segment. A trailing `/` denotes a directory (recurse on
export). Invalid entries are dropped with a `[Plugin]` warning so
a bad manifest can't smuggle a path-traversal opportunity into
the importer's allowlist.
Returns a list of normalized strings. Returns `[]` when the
manifest doesn't declare any exportable server files (the common
case — most plugins keep state purely in localStorage).
"""
if not isinstance(settings_field, dict):
return []
raw = settings_field.get("server_files")
if raw is None:
return []
if not isinstance(raw, list):
log.warning(
"Plugin %r: settings.server_files must be a list, got %s; ignoring",
plugin_id, type(raw).__name__,
)
return []
cleaned: list[str] = []
for entry in raw:
if not isinstance(entry, str) or not entry:
log.warning(
"Plugin %r: dropping non-string / empty server_files entry %r",
plugin_id, entry,
)
continue
# Loader rules mirror what `_validate_relpath` enforces at import
# time, so any entry that passes here is guaranteed to round-trip
# through export and back through import. Surfacing whitespace /
# `.` / dotfile entries as warnings beats silently producing a
# bundle that the same server later refuses to ingest.
if entry != entry.strip():
log.warning(
"Plugin %r: dropping server_files entry with leading/trailing whitespace %r",
plugin_id, entry,
)
continue
# Reject absolute paths, drive letters, and any backslash-
# separated form before splitting — the importer treats the
# allowlist as POSIX strings, so accepting `foo\bar` here would
# let a malicious manifest sidestep traversal detection on
# platforms whose `Path` accepts both separators.
if "\\" in entry:
log.warning(
"Plugin %r: server_files entry must use POSIX separators, dropping %r",
plugin_id, entry,
)
continue
# Strip a single trailing slash for the traversal check, then
# re-attach it so the export walker can still detect "this is
# a directory" from the normalized form.
is_dir = entry.endswith("/")
body = entry.rstrip("/")
if not body:
log.warning("Plugin %r: dropping empty server_files entry", plugin_id)
continue
parts = body.split("/")
if (
body.startswith("/")
or (len(body) >= 2 and body[1] == ":") # Windows drive letter
or any(part in ("", ".", "..") for part in parts)
or parts[0].startswith(".")
):
log.warning(
"Plugin %r: dropping unsafe server_files entry %r "
"(absolute / traversal / dotfile / empty segment)",
plugin_id, entry,
)
continue
cleaned.append(body + ("/" if is_dir else ""))
return cleaned
def _normalize_diagnostics_paths(diagnostics_field, plugin_id: str) -> list[str]:
"""Validate and normalize a plugin's `diagnostics.server_files`
manifest list. Mirrors `_normalize_export_paths` semantics — the
diagnostics export reads files using the same allowlist rules so
every entry that passes here is safe for the bundle assembler to
open without re-validating. Returns `[]` when the manifest doesn't
declare any diagnostic files.
"""
if not isinstance(diagnostics_field, dict):
return []
raw = diagnostics_field.get("server_files")
if raw is None:
return []
if not isinstance(raw, list):
log.warning(
"Plugin %r: diagnostics.server_files must be a list, got %s; ignoring",
plugin_id, type(raw).__name__,
)
return []
cleaned: list[str] = []
for entry in raw:
if not isinstance(entry, str) or not entry:
log.warning(
"Plugin %r: dropping non-string / empty diagnostics.server_files entry %r",
plugin_id, entry,
)
continue
if entry != entry.strip():
log.warning(
"Plugin %r: dropping diagnostics.server_files entry with leading/trailing whitespace %r",
plugin_id, entry,
)
continue
if "\\" in entry:
log.warning(
"Plugin %r: diagnostics.server_files entry must use POSIX separators, dropping %r",
plugin_id, entry,
)
continue
is_dir = entry.endswith("/")
body = entry.rstrip("/")
if not body:
log.warning("Plugin %r: dropping empty diagnostics.server_files entry", plugin_id)
continue
parts = body.split("/")
if (
body.startswith("/")
or (len(body) >= 2 and body[1] == ":")
or any(part in ("", ".", "..") for part in parts)
or parts[0].startswith(".")
):
log.warning(
"Plugin %r: dropping unsafe diagnostics.server_files entry %r "
"(absolute / traversal / dotfile / empty segment)",
plugin_id, entry,
)
continue
cleaned.append(body + ("/" if is_dir else ""))
return cleaned
def _parse_diagnostics_callable(diagnostics_field, plugin_id: str) -> str | None:
"""Validate `diagnostics.callable` shape (`"<module>:<function>"`)
and return the literal spec string. Resolution happens lazily when
the export endpoint actually needs the callable, so a missing
sibling at load time doesn't fail plugin registration.
"""
if not isinstance(diagnostics_field, dict):
return None
spec = diagnostics_field.get("callable")
if spec is None:
return None
if not isinstance(spec, str) or ":" not in spec:
log.warning(
"Plugin %r: diagnostics.callable must be a string of the form "
"'<module>:<function>', got %r; ignoring",
plugin_id, spec,
)
return None
module_name, _, fn_name = spec.partition(":")
if not module_name or not fn_name:
log.warning(
"Plugin %r: diagnostics.callable %r is missing module or function name; ignoring",
plugin_id, spec,
)
return None
# Validate module_name matches load_sibling() constraints: bare name,
# no dots, no slashes, no .py suffix. A malformed spec would only
# surface as an error at export time; reject it here consistently.
if (
"/" in module_name
or "\\" in module_name
or "." in module_name
or module_name.endswith(".py")
):
log.warning(
"Plugin %r: diagnostics.callable module %r must be a bare "
"module name (no dots, slashes, or .py suffix); ignoring",
plugin_id, module_name,
)
return None
return spec
def _install_requirements(plugin_dir: Path, plugin_id: str):
"""Install plugin requirements.txt to a persistent location."""
req_file = plugin_dir / "requirements.txt"
if not req_file.exists():
return True
_PIP_TARGET.mkdir(parents=True, exist_ok=True)
pip_target = str(_PIP_TARGET)
# Add to sys.path if not already there
if pip_target not in sys.path:
sys.path.insert(0, pip_target)
# Check if already installed (marker file). Use a deterministic
# digest — Python's built-in hash() is randomised per process
# (PYTHONHASHSEED), so the marker would never match on restart and
# pip would re-resolve every plugin's requirements on every boot.
marker = _PIP_TARGET / f".installed_{plugin_id}"
req_hash = hashlib.sha256(req_file.read_bytes()).hexdigest()
if marker.exists() and marker.read_text().strip() == req_hash:
return True # Already installed, same requirements
log.info("Installing requirements for plugin %r (this can take a while for large deps)...", plugin_id)
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install",
"--target", pip_target,
"--quiet",
"-r", str(req_file)],
capture_output=True, text=True, timeout=1800,
)
if result.returncode == 0:
marker.write_text(req_hash)
log.info("Requirements installed for plugin %r", plugin_id)
return True
else:
err_lower = result.stderr.lower() if result.stderr else ""
if "read-only" in err_lower or "permission denied" in err_lower:
log.warning(
"Plugin %r: optional dependencies not installed — "
"functionality may be limited. Install dependencies manually "
"or configure an external service if available.",
plugin_id,
)
else:
log.warning("Plugin %r: failed to install requirements: %s", plugin_id, result.stderr[:300])
return False
except Exception as e:
err_lower = str(e).lower()
if "read-only" in err_lower or "permission denied" in err_lower:
log.warning(
"Plugin %r: optional dependencies not installed — "
"functionality may be limited. Install dependencies manually "
"or configure an external service if available.",
plugin_id,
)
else:
log.warning("Plugin %r: error installing requirements: %s", plugin_id, e)
return False
def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=None):
"""Discover and load all plugins from built-in and user directories.
progress_cb, when provided, receives structured progress events:
{
"phase": "<phase-id>",
"message": "<human text>",
"plugin_id": "<id or ''>",
"loaded": <int>,
"total": <int>,
"error": "<optional error text>"
}
route_setup_fn, when provided, is called instead of directly invoking
`routes_module.setup(app, ctx)`. Callers that load plugins from a
background thread can pass a hook that marshals the call back to the
main thread (e.g. via loop.call_soon_threadsafe) to keep FastAPI/
Starlette router mutation on the event-loop thread.
Signature: route_setup_fn(fn: Callable[[], None]) -> None
where `fn` is a zero-argument callable that performs the setup call.
"""
def _emit_progress(phase: str, message: str, plugin_id: str = "", loaded: int = 0,
total: int = 0, error: str | None = None,
clear_error: bool = False):
if not progress_cb:
return
try:
event: dict = {
"phase": phase,
"message": message,
"plugin_id": plugin_id,
"loaded": loaded,
"total": total,
}
# Include the error key only when meaningful:
# - A non-null error string sets/updates the error field.
# - clear_error=True sends an explicit null to clear a
# previously-reported error (e.g. bundled failure cleared
# by a successful user-copy fallback). Downstream handlers
# must check `"error" in event`, not `event.get("error") is
# not None`, to receive the clear signal.
# - No error kwarg → key is omitted; downstream preserves
# any previously-reported error across non-error events.
if error is not None:
event["error"] = error
elif clear_error:
event["error"] = None
progress_cb(event)
except Exception:
# Progress reporting must never break plugin startup.
pass
# Re-entrancy: a fresh load_plugins() pass owns the published state from
# scratch. Clearing BOTH structures at the START (rather than an atomic
# clear()+extend() at the END) lets us publish each plugin incrementally
# as it graduates, so /api/plugins reflects ready plugins the moment they
# are usable instead of all-at-once when the slowest install finishes.
# Tests and dev "reload plugins" re-invoke this; the clear keeps repeated
# passes from accumulating duplicates while preserving list identity.
#
# Bump the load generation and capture it locally so every registry
# mutation below can verify it's still the latest pass before publishing.
# Without this, a background loader from an earlier pass could call
# _graduate()/_mark_failed() *after* this pass cleared the registries,
# re-inserting a plugin it already graduated (a duplicate) or resurrecting
# a stale "installing" entry. See _LOAD_GENERATION.
global _LOAD_GENERATION
with PLUGINS_LOCK:
_LOAD_GENERATION += 1
my_generation = _LOAD_GENERATION
LOADED_PLUGINS.clear()
PENDING_PLUGINS.clear()
def _is_current_generation() -> bool:
"""Return True iff this load pass is still the latest one. Callers MUST
already hold PLUGINS_LOCK (generation reads/writes are lock-guarded)."""
return _LOAD_GENERATION == my_generation
def _loaded_count() -> int:
with PLUGINS_LOCK:
return len(LOADED_PLUGINS)
def _mark_failed(plugin_id: str, error: str) -> None:
"""Flip a pending plugin's status to "failed" with error text so it
stays a visible, disabled nav entry. No-op if the plugin already
graduated (it can't fail after becoming ready) or if a newer load pass
has superseded this one."""
with PLUGINS_LOCK:
if not _is_current_generation():
return
entry = PENDING_PLUGINS.get(plugin_id)
if entry is not None:
entry["status"] = "failed"
entry["error"] = error
def _graduate(entry: dict) -> int:
"""Move a plugin from pending to loaded (ready). Inserts into
LOADED_PLUGINS at the slot dictated by its discovery order (`_order`)
so the published list stays in discovery order even when earlier
plugins failed (leaving gaps) or a user-copy fallback graduates out of
sequence after the main loop. Pops the pending entry under the same
lock so a reader never sees the plugin in both structures. No-op (other
than returning the current count) if a newer load pass has superseded
this one, so a stale background loader can't re-insert into a registry
a newer pass already cleared. Returns the new ready count."""
order = entry.get("_order", 0)
with PLUGINS_LOCK:
if not _is_current_generation():
return len(LOADED_PLUGINS)
# Carry the latest runtime enable/disable intent. The ready entry was
# rebuilt from the startup `_spec_entries` snapshot, but the toggle
# endpoint may have flipped (and persisted) the live pending entry's
# `enabled` flag while this plugin was still installing/loading. Both
# the endpoint and this read happen under PLUGINS_LOCK, so without
# carrying it forward a disable that arrived mid-load would silently
# revert here and re-expose the plugin (and its capabilities) until
# the next restart.
_pending = PENDING_PLUGINS.get(entry["id"])
if _pending is not None and "enabled" in _pending:
entry["enabled"] = _pending["enabled"]
pos = sum(1 for e in LOADED_PLUGINS if e.get("_order", 0) < order)
LOADED_PLUGINS.insert(pos, entry)
PENDING_PLUGINS.pop(entry["id"], None)
return len(LOADED_PLUGINS)
# Collect plugin directories — user plugins first so they override built-in
plugin_dirs = []
user_plugins_dir = os.environ.get("FEEDBACK_PLUGINS_DIR") or os.environ.get("SLOPSMITH_PLUGINS_DIR")
if user_plugins_dir:
user_path = Path(user_plugins_dir)
if user_path.is_dir() and user_path != PLUGINS_DIR:
plugin_dirs.append(user_path)
if PLUGINS_DIR.is_dir():
plugin_dirs.append(PLUGINS_DIR)
if not plugin_dirs:
_emit_progress("plugins-complete", "No plugin directories found", loaded=0, total=0)
return
# Add persistent pip target to sys.path
pip_target = str(_PIP_TARGET)
if _PIP_TARGET.exists() and pip_target not in sys.path:
sys.path.insert(0, pip_target)
loaded_ids = set()
# id → (plugin_id, plugin_dir, manifest) for the *kept* copy of each
# plugin id. Used by the duplicate-skip path to log a useful
# "user copy at X overriding bundled core plugin at Y" message
# instead of a generic "skipping duplicate" line. Mirrors loaded_ids
# in lifetime; both are local to this discovery pass.
loaded_specs_by_id: dict[str, tuple] = {}
# Maps plugin_id → evicted user spec (plugin_id, plugin_dir, manifest).
# Populated when a bundled plugin evicts a user-installed copy. Used as
# a fallback: if the bundled copy later fails to load its routes, the
# user copy is restored so the server remains functional.
_pending_evictions: dict[str, tuple] = {}
# Maps plugin_id → set of sys.modules keys that were NEW during the
# failed bundled route load. Bundled routes may import helpers under
# bare names (e.g. `import helper`); these survive the namespaced
# _parent_pkg cleanup and would resolve to bundled code if the fallback
# plugin also uses bare imports. Purging them gives the fallback a
# clean import slate (Thread 1, review-4226783807).
_pending_eviction_stale_modules: dict[str, set] = {}
def _is_bundled(pdir: Path, mf: dict) -> bool:
"""Return True iff pdir is the real in-tree bundled core plugin.
Requires ALL THREE of:
- Located directly in PLUGINS_DIR (pdir.parent == PLUGINS_DIR)
- Manifest carries ``"bundled": true``
- Directory name matches the plugin id (pdir.name == mf.get("id"))
The directory-name check distinguishes the real in-tree copy from a
verbatim user copy placed in plugins/ under a different folder name
but still carrying ``"bundled": true`` from the source manifest.
Neither the directory location alone nor the manifest field alone is
sufficient — a user plugin cloned into plugins/ would pass the first
check, and a user plugin could forge the second. The name check ties
the directory to the specific plugin id so only the canonical
``plugins/<id>/`` location passes all three.
"""
return (
pdir.parent == PLUGINS_DIR
and bool(mf.get("bundled"))
and pdir.name == mf.get("id")
)
# Two-pass discovery so we can warn about cross-plugin module-name
# collisions BEFORE any plugin's setup runs (feedBack#33). The
# first pass collects (plugin_id, plugin_dir, manifest) tuples in
# load order; the second pass actually executes each plugin's
# setup with a per-plugin context.
plugin_load_specs = []
for plugins_base_dir in plugin_dirs:
for plugin_dir in sorted(plugins_base_dir.iterdir()):
if not plugin_dir.is_dir():
continue
manifest_path = plugin_dir / "plugin.json"
if not manifest_path.exists():
continue
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as e:
log.warning("Failed to read plugin manifest %s: %s", manifest_path, e)
continue
plugin_id = manifest.get("id")
if plugin_id is None:
# No `id` key at all — silently skip (existing
# behavior; manifests without an id were never
# meant to be valid).
continue
# Type-check BEFORE the empty check: falsy non-string
# values (`{"id": 0}`, `{"id": []}`) should produce the
# explicit "must be a string" warning, not be silently
# dropped. Spotted by Copilot review on PR #105 round 4.
if not isinstance(plugin_id, str):
log.warning(
"Skipping %s: 'id' must be a string, got %s (%r)",
manifest_path, type(plugin_id).__name__, plugin_id,
)
continue
if not plugin_id:
# Empty-string id — silently skip (matches the
# original `if not plugin_id: continue` semantics
# for empty strings).
continue
if plugin_id in loaded_ids:
# Duplicate id — pick a winner. Bundled plugins always win.
# `loaded_specs_by_id` records the already-seen copy; this
# is the new candidate. Use specific log messages so it's
# obvious which copy wins and why.
kept = loaded_specs_by_id.get(plugin_id)
# Bundled-ness requires ALL THREE: the in-tree PLUGINS_DIR
# location, the manifest's ``"bundled": true`` flag, AND
# the directory name matching the manifest id. See the
# ``_is_bundled`` helper defined above for the full contract.
this_is_bundled = _is_bundled(plugin_dir, manifest)
kept_is_bundled = _is_bundled(kept[1], kept[2]) if kept else False
if this_is_bundled and not kept_is_bundled:
# The incoming copy is the canonical bundled plugin; the
# already-kept copy is user-installed (FEEDBACK_PLUGINS_DIR
# or cloned directly into plugins/). Bundled always wins —
# evict the user copy and fall through to register the
# bundled version instead.
#
# Store the evicted spec as a potential fallback: if the
# bundled copy later fails to load its routes, the server
# restores this user copy so it keeps working.
_pending_evictions[plugin_id] = kept
log.warning(
"User-installed copy of bundled plugin %r at %s ignored; "
"using bundled version at %s.",
plugin_id, kept[1] if kept else "(unknown)", plugin_dir,
)
# Replace the user copy's slot in-place so the bundled
# copy inherits the same discovery position. Removing and
# re-appending would shift the bundled entry to the end of
# plugin_load_specs, changing /api/plugins order and the
# frontend playSong wrapper chain.
_user_slot = next(
(i for i, s in enumerate(plugin_load_specs) if s[0] == plugin_id),
None,
)
_bundled_spec = (plugin_id, plugin_dir, manifest)
if _user_slot is not None:
plugin_load_specs[_user_slot] = _bundled_spec
else:
plugin_load_specs.append(_bundled_spec)
loaded_specs_by_id[plugin_id] = _bundled_spec
continue # loaded_ids already contains plugin_id
elif this_is_bundled and kept_is_bundled:
# Two bundled plugins share an id — shouldn't happen in a
# well-maintained tree, but emit a clear warning so it
# doesn't pass silently.
log.warning(
"Skipping duplicate bundled plugin %r at %s (already registered from %s)",
plugin_id, plugin_dir, kept[1] if kept else "(unknown)",
)
continue
elif kept_is_bundled:
# A non-bundled (user) copy encountered after an already-kept
# bundled copy. Bundled always wins — discard the user copy.
# Store as a potential fallback: if the bundled copy later
# fails to load its routes, the server restores this user copy
# so it keeps working. Only the first user copy encountered is
# kept as the fallback (subsequent duplicates are dropped).
if plugin_id not in _pending_evictions:
_pending_evictions[plugin_id] = (plugin_id, plugin_dir, manifest)
log.warning(
"User-installed copy of bundled plugin %r at %s ignored; "
"using bundled version at %s.",
plugin_id, plugin_dir, kept[1] if kept else "(unknown)",
)
continue
else:
log.warning("Skipping duplicate plugin %r at %s", plugin_id, plugin_dir)
continue
loaded_ids.add(plugin_id)
plugin_load_specs.append((plugin_id, plugin_dir, manifest))
loaded_specs_by_id[plugin_id] = (plugin_id, plugin_dir, manifest)
# Warn before loading so authors see the message even if a colliding
# plugin's setup itself blows up later in the loop.
_warn_on_module_collisions(
[(plugin_id, plugin_dir) for plugin_id, plugin_dir, _ in plugin_load_specs]
)
_emit_progress(
"plugins-discovered",
f"Discovered {len(plugin_load_specs)} plugin(s)",
loaded=0,
total=len(plugin_load_specs),
)
def _nav_entry(plugin_id: str, plugin_dir: Path, manifest: dict, order: int) -> dict:
"""Build the manifest-derived nav fields shared by a pending entry and
a graduated (ready) entry. Carries everything /api/plugins needs to
render the nav slot — name, nav, type, bundled flag, version, and the
has_* capability booleans — without importing the plugin's code.
Also carries the capability-pipelines.v1 metadata (standards,
validated capabilities, validation warnings, compatibility shims,
settings schema, UI contributions, runtime domains), all
manifest-derived."""
_validated_capabilities, _capability_validation_warnings, _capability_unsupported_versions = (
_capability_warnings(manifest, plugin_id)
)
# v3 Pedalboard metadata (additive, optional). `description` is a short
# human sentence; `icon` is an assets-relative thumbnail path. When the
# manifest omits `icon` we auto-detect the conventional assets/thumb.png
# so a plugin only needs to ship the file (no manifest edit) to get a
# real pedal graphic. Both are served via the existing sandboxed
# /api/plugins/<id>/assets/... route (safe_join guards traversal at
# serve time), so we carry the raw path here like `styles`.
_description = manifest.get("description")
if not isinstance(_description, str) or not _description:
_description = None
_category = manifest.get("category")
if not isinstance(_category, str) or not _category:
_category = None
# Settings-tab placement (tabbed settings page). When `settings` is a
# dict, an optional `category` field names which settings tab the
# plugin's panel mounts under (e.g. "graphics", "mic", "progression").
# Distinct from the top-level `category` above (which drives Pedalboard
# grouping) so the two don't collide. Absent/blank → None → the
# frontend falls back to the generic "Plugins" tab.
_settings_manifest = manifest.get("settings")
_settings_category = None
if isinstance(_settings_manifest, dict):
_sc = _settings_manifest.get("category")
if isinstance(_sc, str) and _sc:
_settings_category = _sc
_icon = manifest.get("icon")
if not isinstance(_icon, str) or not _icon:
_icon = None
try:
if (plugin_dir / "assets" / "thumb.png").is_file():
_icon = "assets/thumb.png"
except OSError:
_icon = None
# Immersive (full-screen) screen opt-in. A plugin that declares a
# top-level `"fullscreen": true` gets the whole content area when its
# screen is active: the v3 shell hides the topbar and collapses the
# sidebar to an icon rail (see static/v3/shell.js + v3.css). For
# DAW-style plugin UIs that need the viewport, not a scrolling content
# page. Strict `is True` so a stray truthy value can't silently opt in.
_fullscreen = manifest.get("fullscreen") is True
return {
"id": plugin_id,
"name": manifest.get("name", plugin_id),
"nav": manifest.get("nav"),
"type": manifest.get("type"),
# `enabled` defaults true; the authoritative value (from persisted
# plugin_state.json) is stamped onto this entry in the pending-seed
# loop, before it is published or graduated (v3 Pedalboard footswitch).
"enabled": True,
"description": _description,
"category": _category,
"icon": _icon,
"bundled": _is_bundled(plugin_dir, manifest),
"version": manifest.get("version"),
"has_screen": bool(manifest.get("screen")),
"has_script": bool(manifest.get("script")),
# Module-migration (R0): `scriptType:"module"` tells the loader to
# inject screen.js as <script type="module">; `minHost` is the
# min core version a migrated plugin needs (passthrough only in R0 —
# enforcement is deferred to R4, master §4b). None when unset.
"script_type": manifest.get("scriptType"),
"min_host": manifest.get("minHost"),
"has_settings": bool(manifest.get("settings")),
"settings_category": _settings_category,
# Drives the v3 shell's immersive (full-screen) mode for this
# plugin's screen. False unless the manifest declares it explicitly.
"fullscreen": _fullscreen,
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
# `styles` is an optional relpath (under the plugin's assets/) to a
# compiled, preflight-off stylesheet the frontend injects as a
# <link> so runtime-installed plugins style correctly without core
# rescanning them at build time (Principle II). `has_styles` mirrors
# the other manifest-only booleans; `styles` carries the path so the
# client can build the asset URL without a second manifest read.
"has_styles": bool(manifest.get("styles")),
"styles": manifest.get("styles"),
"standards": _normalize_string_list(manifest.get("standards")),
"capabilities": _validated_capabilities,
"capability_validation_warnings": _capability_validation_warnings,
"capability_unsupported_versions": _capability_unsupported_versions,
"compatibility_shims": _compatibility_shims_from_manifest(manifest, plugin_id),
"settings_schema": _normalize_manifest_mapping(manifest.get("settings_schema")),
"ui_contributions": _normalize_ui_contributions(manifest),
"runtime_domains": _normalize_runtime_domains(manifest),
"_order": order,
}
# Record EVERY discovered plugin as a pending "installing" nav entry up
# front, in discovery order, so /api/plugins can render the full nav
# immediately — before any (potentially 20-30 min) dependency install
# runs. A plugin graduates out of here into LOADED_PLUGINS when ready;
# on failure it stays here flipped to "failed". `_spec_order` maps each
# kept plugin_id to its discovery index so a user-copy fallback graduating
# after the main loop can reclaim the bundled plugin's original nav slot.
_spec_order: dict[str, int] = {}
# Cache each kept plugin's base nav entry so graduation can dict()-copy it
# instead of re-deriving via _nav_entry() (which re-runs the three-part
# _is_bundled() filesystem check and _is_valid_tour_manifest()). Besides
# the wasted work, a second computation could disagree with the first if
# the filesystem changed mid-load (container overlay, plugin deleted), so
# the pending entry and the ready entry are guaranteed to describe the same
# plugin. _order is the only mutable field and it's identical here.
_spec_entries: dict[str, dict] = {}
# Persisted enable/disable state (v3 Pedalboard footswitch). Loaded once,
# OUTSIDE PLUGINS_LOCK, since it does filesystem IO. A plugin marked
# disabled here is surfaced as an "off" pedal but never has its routes /
# screen / nav / capabilities registered — `_disabled_ids` carries that
# decision to the main load loop below.
_plugin_state = _load_plugin_state()
_disabled_ids: set[str] = set()
with PLUGINS_LOCK:
stale = not _is_current_generation()
for idx, (plugin_id, plugin_dir, manifest) in enumerate(plugin_load_specs):
_spec_order[plugin_id] = idx
base = _nav_entry(plugin_id, plugin_dir, manifest, idx)
# Stamp the authoritative enabled flag onto the cached base so it
# flows to BOTH the pending entry (dict-copied below) and the ready
# entry (dict-copied at graduation).
enabled = _is_plugin_enabled(_plugin_state, plugin_id)
base["enabled"] = enabled
_spec_entries[plugin_id] = base
# A newer load pass already owns the registries — build the local
# caches (used by this pass's bookkeeping) but don't publish.
if stale:
continue
if not enabled:
# Disabled: publish a visible, disabled nav entry and record the
# id so the main loop skips requirements/routes/graduation. Its
# capabilities are suppressed at the /api/plugins boundary so a
# disabled plugin contributes nothing to the capability pipeline.
_disabled_ids.add(plugin_id)
entry = dict(base)
entry["status"] = "disabled"
entry["error"] = None
PENDING_PLUGINS[plugin_id] = entry
continue
entry = dict(base)
entry["status"] = "installing"
entry["error"] = None
PENDING_PLUGINS[plugin_id] = entry
# Track plugin_ids whose routes.setup() raised an exception, so we
# can fall back to evicted user copies for those plugin_ids below.
_route_failed_ids: set[str] = set()
# Track plugin_ids whose bundled setup() timed out while already
# running (mid-flight). For those, activating the fallback is unsafe
# because the original setup() may still be mutating the router
# concurrently — fallback routes would mount on top of partial bundled
# routes, producing duplicate or conflicting endpoints.
_route_mid_flight_ids: set[str] = set()
def _became_disabled(pid: str) -> bool:
"""Live re-check (under PLUGINS_LOCK) whether the toggle endpoint has
disabled this plugin AFTER the startup snapshot — e.g. while a slow
earlier plugin (or this plugin's own requirements) was still installing.
If so, flip its pending entry to "disabled" and report it so the loader
skips the mount. Called both before install and again right before route
setup so a disable that lands anywhere before mounting — including during
a slow pip install — actually prevents the mount (rather than mounting
and only suppressing post-graduation).
The residual race is narrow and inherent: a disable arriving AFTER the
final check but during the route-module import (exec_module) or its
setup() call still mounts. That window is sub-second and cannot be closed
without holding PLUGINS_LOCK across third-party plugin code (setup() may
call back into lock-taking host APIs like register_library_provider, so
holding it there risks deadlock). Such a plugin still graduates carrying
enabled=False (see _graduate) — capabilities suppressed, nav hidden — and
its routes stay until the next restart, per the documented runtime
semantics.
Reads BOTH the persisted state and the in-memory pending flag: the
toggle endpoint writes plugin_state.json BEFORE it flips the in-memory
flag, so a disable can be on disk a hair before it is in memory.
Consulting the durable on-disk state (the source of truth on restart)
closes that tiny persist-then-flip window without holding a lock across
file IO. _is_plugin_enabled() also keeps always-on plugins immune."""
persisted_off = not _is_plugin_enabled(_load_plugin_state(), pid)
with PLUGINS_LOCK:
_p = PENDING_PLUGINS.get(pid)
if _p is None:
return False
if persisted_off or _p.get("enabled") is False:
_p["status"] = "disabled"
_p["enabled"] = False
_p["error"] = None
return True
return False
for idx, (plugin_id, plugin_dir, manifest) in enumerate(plugin_load_specs):
# Persisted as disabled (v3 Pedalboard footswitch): leave it in
# PENDING_PLUGINS as a visible "off" pedal and register NOTHING — no
# requirements, no routes.setup(), no screen/nav, no capabilities.
# Re-enabling takes full effect on the next restart.
if plugin_id in _disabled_ids:
continue
# Honor a runtime disable that landed before this plugin's iteration
# (skips a potentially long pip install entirely).
if _became_disabled(plugin_id):
_disabled_ids.add(plugin_id)
continue
_emit_progress(
"plugin-start",
f"Loading plugin '{plugin_id}'",
plugin_id=plugin_id,
# Report the ready-plugin count, not the loop index: `loaded` means
# "ready plugins" everywhere else, so emitting idx here would let
# /api/startup-status jump backwards (idx 3 → _loaded_count() 1)
# mid-run and break the implied monotonic counter.
loaded=_loaded_count(),
total=len(plugin_load_specs),
)
# Install plugin requirements if present
_emit_progress(
"plugin-requirements",
f"Installing requirements for '{plugin_id}' (if needed)",
plugin_id=plugin_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
)
req_ok = _install_requirements(plugin_dir, plugin_id)
if not req_ok:
# Non-fatal: a pip failure may just mean an OPTIONAL dependency
# couldn't be installed (read-only filesystem, an extra a plugin
# degrades gracefully without). We surface the error but still try
# to load routes. If the plugin genuinely needs the missing dep its
# routes will fail to import below and it becomes "failed" there —
# so a real install failure still surfaces as a visible, disabled
# nav entry (ADR 0001) without disabling plugins that work anyway.
_emit_progress(
"plugin-error",
f"Failed to install requirements for '{plugin_id}'",
plugin_id=plugin_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
error="Requirements installation failed; check server logs for details",
)
# Authoritative final re-check: a disable may have landed WHILE this
# plugin's own requirements were installing (the install above can be
# slow). This is the last point before we touch sys.path / import the
# routes module / run setup(), so honoring it here means a disable that
# arrives any time before route setup truly prevents the mount.
if _became_disabled(plugin_id):
_disabled_ids.add(plugin_id)
continue
# Add plugin directory to sys.path so the plugin's bare
# `import sibling` keeps working during the feedBack#33
# transition. New plugins should prefer
# `context['load_sibling']('sibling')` instead — see
# CLAUDE.md / Plugin System / Backend routes.
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
# Build a per-plugin context: dict-copy the shared mapping
# so plugin A re-binding `ctx['x']` doesn't leak into plugin
# B's view, then add a `load_sibling` closure scoped to THIS
# plugin's id + dir. (Note: the COPY is shallow — values
# stored in context are still the same objects across
# plugins, so e.g. `ctx['meta_db']` mutations are still
# observable everywhere by design.) The helper namespaces
# sibling modules as `plugin_<id>.<name>` (with plugin_id
# bijectively encoded by _safe_plugin_id_for_module_name:
# `_` -> `_5f_`, `.` -> `_2e_`) so two plugins shipping the
# same filename get distinct cached modules. See
# feedBack#33.
plugin_context = dict(context)
plugin_context["load_sibling"] = (
lambda name, _pid=plugin_id, _pdir=plugin_dir:
_load_plugin_sibling(_pid, _pdir, name)
)
plugin_context["log"] = logging.getLogger(f"feedBack.plugin.{plugin_id}")
if callable(plugin_context.get("register_library_provider")):
_register_library_provider = plugin_context["register_library_provider"]
def _register_scoped_library_provider(provider, *args, _pid=plugin_id, _base=_register_library_provider, **kwargs):
# Force (not setdefault) the owner attribution to the loading
# plugin: owner_plugin_id drives provider attribution and the
# library capability participant id, so a plugin must not be
# able to pass a forged value and impersonate another plugin.
kwargs["owner_plugin_id"] = _pid
return _base(provider, *args, **kwargs)
plugin_context["register_library_provider"] = _register_scoped_library_provider
# Load routes using importlib to avoid module name collisions.
# `route_ok` gates graduation: only a plugin that installs AND
# registers its routes cleanly becomes ready. A route failure leaves
# it "failed" in PENDING_PLUGINS (the fallback block below may still
# graduate a user-copy for an evicted bundled plugin).
route_ok = True
routes_file = manifest.get("routes")
if routes_file:
_emit_progress(
"plugin-routes",
f"Loading routes for '{plugin_id}'",
plugin_id=plugin_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
)
# Capture the current route count so we can detect whether
# setup() registered any handlers before raising. FastAPI has
# no route-removal API, so partial registration is permanent.
_routes_before = len(getattr(app, "routes", []))
try:
# Escape `.` in plugin_id the same way load_sibling
# does. Without it, a plugin id like
# `com.example.foo` would land at
# `plugin_com.example.foo_routes` — which Python
# parses as a dotted module path, sets
# `__package__` to `plugin_com.example`, and breaks
# any relative imports inside routes.py. Spotted by
# Copilot review on PR #105 round 2.
module_name = f"plugin_{_safe_plugin_id_for_module_name(plugin_id)}_routes"
spec = importlib.util.spec_from_file_location(
module_name, str(plugin_dir / routes_file))
routes_module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = routes_module
spec.loader.exec_module(routes_module)
if hasattr(routes_module, "setup"):
if route_setup_fn is not None:
# Bind routes_module and plugin_context by value so
# the callable is safe regardless of when/how
# route_setup_fn dispatches it — avoids late-binding
# closure bugs if the caller defers execution.
_fn = lambda rm=routes_module, ctx=plugin_context: rm.setup(app, ctx)
_fn._plugin_id = plugin_id
route_setup_fn(_fn)
else:
routes_module.setup(app, plugin_context)
log.info("Loaded routes for plugin %r", plugin_id)
except Exception as e:
log.exception("Failed to load routes for plugin %r", plugin_id)
route_ok = False
_route_failed_ids.add(plugin_id)
# If this was a mid-flight timeout, mark the plugin so the
# fallback block skips it — the original setup() may still be
# running and registering routes concurrently; mounting a
# fallback on top would produce duplicate/conflicting endpoints.
if getattr(e, "setup_mid_flight", False):
_route_mid_flight_ids.add(plugin_id)
# Detect partial route registration: if setup() mounted any
# handlers before raising, those routes stay permanently (no
# FastAPI deregistration API). Warn loudly so maintainers can
# identify conflicting endpoints in the server log.
_routes_after = len(getattr(app, "routes", []))
if _routes_after > _routes_before:
log.warning(
"Plugin %r registered %d route(s) before its setup() raised; "
"these handlers cannot be removed and may conflict with any fallback.",
plugin_id, _routes_after - _routes_before,
)
# Compute bare-import modules added during the failed load.
# IMPORTANT: Filter strictly to modules whose __file__ lives
# inside this plugin's directory — the naive set-diff would
# capture every module imported by any concurrent thread
# (metadata scan, stdlib lazy imports, etc.) between the
# snapshot and the failure, causing the fallback block to
# delete unrelated entries from sys.modules.
# Purge them NOW (not deferred) so subsequent plugins in the
# main loop don't accidentally resolve this plugin's helpers
# when they do bare `import helper`. The fallback block's
# per-key pop() is a harmless no-op when the keys are already
# absent.
if plugin_id in _pending_evictions:
_plugin_dir_prefix = str(plugin_dir) + os.sep
_stale = set()
# Scan ALL cached modules (not only those newly added since
# the snapshot) for any whose __file__ lives inside this
# plugin's directory. A previous load_plugins() call (test
# reload, dev restart) may have left same-named helpers from
# the bundled copy in sys.modules before this run's
# snapshot was taken; diffing against the snapshot alone
# would miss those and let the fallback copy resolve the
# old bundled code on repeated loads.
for _k, _mod in list(sys.modules.items()):
_mf = getattr(_mod, "__file__", None)
if _mf and str(_mf).startswith(_plugin_dir_prefix):
_stale.add(_k)
_pending_eviction_stale_modules[plugin_id] = _stale
# Purge immediately to prevent module leakage into later plugins.
for _k in _stale:
sys.modules.pop(_k, None)
# str(e) is empty for common no-arg exceptions (e.g.
# concurrent.futures.TimeoutError()), which would leave the
# plugin "failed" but with a blank tooltip in /api/plugins and a
# blank startup-status error. Fall back to repr(e) so the error
# text is always non-empty and identifies the failure type.
_err_text = str(e) or repr(e)
_mark_failed(plugin_id, _err_text)
_emit_progress(
"plugin-error",
f"Failed loading routes for '{plugin_id}'",
plugin_id=plugin_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
error=_err_text,
)
if not route_ok:
# Not ready: leave it "failed" in PENDING_PLUGINS so it shows as a
# disabled nav entry. If it's a bundled plugin that evicted a user
# copy, the fallback block below may still graduate that copy.
continue
# Graduate: dependencies are installed and routes registered, so the
# plugin is ready. Publish it incrementally — readers (and the SSE
# `plugin-registered` event that drives the frontend re-fetch) see it
# the moment it's usable, not when the slowest sibling finishes.
# Reuse the base nav entry computed during discovery rather than
# re-deriving it, so the ready entry can't disagree with the pending
# one (see _spec_entries).
loaded_entry = dict(_spec_entries[plugin_id])
loaded_entry.update({
"status": "ready",
# Normalized list of relpaths under CONFIG_DIR that this
# plugin opts in to settings export/import. Empty for
# plugins that don't declare `settings.server_files`. See
# feedBack#113.
"_export_paths": _normalize_export_paths(manifest.get("settings"), plugin_id),
# Diagnostics opt-in (feedBack#166): same allowlist semantics
# as `_export_paths` but for the troubleshooting bundle.
"_diagnostics_paths": _normalize_diagnostics_paths(manifest.get("diagnostics"), plugin_id),
"_diagnostics_callable_spec": _parse_diagnostics_callable(manifest.get("diagnostics"), plugin_id),
"_load_sibling": plugin_context["load_sibling"],
"_dir": plugin_dir,
"_manifest": manifest,
})
_new_count = _graduate(loaded_entry)
log.info("Registered plugin %r (%s)", plugin_id, manifest.get("name", ""))
_emit_progress(
"plugin-registered",
f"Registered plugin '{plugin_id}'",
plugin_id=plugin_id,
loaded=_new_count,
total=len(plugin_load_specs),
)
# If any bundled plugin failed to load its routes AND it evicted a
# user-installed copy during discovery, fall back to that user copy so
# the server remains functional. A bad bundled release should never
# leave the plugin completely broken when a working user copy exists.
#
# NOTE on partial-registration: if the bundled setup() managed to register
# some FastAPI routes before raising, those handlers stay permanently (no
# route-removal API). The partial-registration warning above names the
# count; the fallback copy's routes then mount alongside them, so duplicate
# or conflicting endpoints are possible. This is an accepted limitation;
# the primary mitigation is thorough testing of bundled releases.
#
# NOTE on timeout race: in async mode the bundled setup() runs on the
# event-loop thread via route_setup_fn. If it times out (>60 s) while
# setup() has ALREADY STARTED executing, `_route_mid_flight_ids` is set
# for that plugin and the fallback is skipped — the original setup() may
# still be mutating the router concurrently and mounting a second set of
# routes on top would produce duplicate/conflicting endpoints. The
# mid-flight case is detected by the `setup_mid_flight` attribute on the
# TimeoutError re-raised by _route_setup_on_main (server.py).
# If the timeout fires BEFORE _do() has started, the _cancelled flag
# in _route_setup_on_main prevents the queued callback from executing,
# making the fallback safe in that case.
for evicted_id, evicted_spec in _pending_evictions.items():
if evicted_id not in _route_failed_ids:
continue
if evicted_id in _route_mid_flight_ids:
log.warning(
"Skipping fallback for %r: bundled setup() timed out while already "
"executing; the router may have partial routes from the bundled copy. "
"Restart the server to recover.",
evicted_id,
)
# The broken bundled plugin never graduated (route_ok was False),
# so there is nothing to remove from LOADED_PLUGINS; it stays a
# "failed" pending entry until a restart recovers it.
continue
_ev_id, ev_dir, ev_manifest = evicted_spec
# Honor a runtime disable before doing any fallback work (mirrors the
# main load loop's _became_disabled checks). A disable that lands before
# the fallback copy mounts must prevent the mount — and skip its install —
# not just suppress it post-graduation.
if _became_disabled(evicted_id):
_disabled_ids.add(evicted_id)
continue
log.warning(
"Bundled plugin %r failed to load routes; "
"falling back to user-installed copy at %s.",
evicted_id, ev_dir,
)
# The fallback reclaims the bundled plugin's original discovery slot so
# /api/plugins order (and the frontend playSong wrapper chain) is
# preserved. The broken bundled copy never graduated, so we just
# graduate the user copy at the bundled plugin's `_order`.
_bundled_orig_order = _spec_order.get(evicted_id, len(plugin_load_specs))
# Ensure the fallback directory is at the FRONT of sys.path so
# its modules take priority over any bundled copy still present.
# Simply inserting when absent is not enough: on repeated
# load_plugins() calls (tests, dev reloads) the user-copy dir may
# already be in sys.path but behind the bundled dir from an earlier
# run, letting bare imports in the fallback still resolve bundled
# files. Always remove-then-reinsert to guarantee front-of-path.
ev_dir_str = str(ev_dir)
if ev_dir_str in sys.path:
sys.path.remove(ev_dir_str)
sys.path.insert(0, ev_dir_str)
ev_context = dict(context)
ev_context["load_sibling"] = (
lambda name, _pid=evicted_id, _pdir=ev_dir:
_load_plugin_sibling(_pid, _pdir, name)
)
ev_context["log"] = logging.getLogger(f"feedBack.plugin.{evicted_id}")
if callable(ev_context.get("register_library_provider")):
_ev_register_library_provider = ev_context["register_library_provider"]
def _register_scoped_fallback_library_provider(provider, *args, _pid=evicted_id, _base=_ev_register_library_provider, **kwargs):
# Force owner attribution to the loading plugin — see the main
# context wrapper above; a forged owner_plugin_id must not be
# able to misattribute providers or collide with another id.
kwargs["owner_plugin_id"] = _pid
return _base(provider, *args, **kwargs)
ev_context["register_library_provider"] = _register_scoped_fallback_library_provider
# Install the fallback copy's requirements. It was evicted before
# the main load loop ran, so _install_requirements was never called
# for it. A user copy that depends on extra packages would otherwise
# fail with an import error even when those packages can be installed.
# Mirror the main load-loop contract: _install_requirements returning
# False is *non-fatal* (read-only filesystem, optional dep, etc.) —
# we emit a plugin-error and continue loading, exactly as the main
# loop does. Treating it as fatal here would break the fallback for
# those same tolerated cases and leave the bundled-failure error
# unresolved.
ev_req_ok = _install_requirements(ev_dir, evicted_id)
if not ev_req_ok:
_emit_progress(
"plugin-error",
f"Failed to install requirements for fallback copy of '{evicted_id}'",
plugin_id=evicted_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
error="Requirements installation failed for fallback copy; check server logs",
)
# Purge any sibling modules the failed bundled copy may have loaded.
# They are cached under the same namespace as what the fallback would use.
# The parent package is `plugin_{safe_id}`, sibling modules are
# `plugin_{safe_id}.{name}` (from load_sibling), and the routes module is
# `plugin_{safe_id}_routes` (note the underscore). Clearing all three
# patterns ensures the fallback gets a clean slate and doesn't accidentally
# resolve bundled helper code that is still cached in sys.modules.
_safe_eid = _safe_plugin_id_for_module_name(evicted_id)
_parent_pkg = f"plugin_{_safe_eid}"
# The routes module is registered under exactly `{_parent_pkg}_routes`
# (underscore, not dot — it is NOT a sub-package of _parent_pkg).
# Using startswith(f"{_parent_pkg}_") would incorrectly match
# "plugin_a_5f_b_routes" (routes for plugin "a_b") when evicting
# plugin "a", because "plugin_a_5f_b_routes".startswith("plugin_a_")
# is True. Match the routes entry exactly instead.
_stale_sibling_keys = [
k for k in list(sys.modules)
if k == _parent_pkg
or k.startswith(f"{_parent_pkg}.")
or k == f"{_parent_pkg}_routes"
]
for _k in _stale_sibling_keys:
del sys.modules[_k]
# Also purge bare-import modules the failed bundled copy may have added
# to sys.modules. These are NOT covered by the namespaced purge above;
# a bundled plugin that does `import helper` (bare import via sys.path)
# would otherwise leave a stale `helper` module in sys.modules that
# the fallback copy could accidentally resolve instead of its own file.
for _k in _pending_eviction_stale_modules.get(evicted_id, set()):
sys.modules.pop(_k, None)
# Final live re-check before importing/mounting the fallback's routes —
# its requirements install above can be slow, so a disable may have
# arrived in the meantime. (Same residual exec_module()/setup() window
# applies as on the main path.)
if _became_disabled(evicted_id):
_disabled_ids.add(evicted_id)
continue
# Re-load the fallback's routes using the same module-name slot so
# it naturally replaces the previously-failed bundled module.
ev_routes_file = ev_manifest.get("routes")
# If the user copy has no routes file it cannot restore the bundled
# plugin's backend endpoints (the route failure is the very reason we
# are in this fallback path). Treat that as a failed recovery so the
# bundled-failure error is NOT cleared from startup-status.
fallback_routes_ok = bool(ev_routes_file)
if ev_routes_file:
# Capture route count before fallback setup() to detect partial
# registration — same permanent-mount limitation as the main loop.
_fallback_routes_before = len(getattr(app, "routes", []))
try:
ev_module_name = f"plugin_{_safe_plugin_id_for_module_name(evicted_id)}_routes"
ev_spec = importlib.util.spec_from_file_location(
ev_module_name, str(ev_dir / ev_routes_file))
ev_routes_module = importlib.util.module_from_spec(ev_spec)
sys.modules[ev_module_name] = ev_routes_module
ev_spec.loader.exec_module(ev_routes_module)
if hasattr(ev_routes_module, "setup"):
if route_setup_fn is not None:
_fn = lambda rm=ev_routes_module, ctx=ev_context, a=app: rm.setup(a, ctx)
_fn._plugin_id = evicted_id
route_setup_fn(_fn)
else:
ev_routes_module.setup(app, ev_context)
log.info("Loaded routes for fallback copy of plugin %r", evicted_id)
except Exception:
log.exception(
"Fallback user-installed copy of %r also failed to load routes; "
"plugin unavailable (not registered).", evicted_id,
)
# Update the failed pending entry + emit a plugin-error so both
# /api/plugins and startup-status reflect the fallback's failure
# as the root cause, not the earlier bundled-copy error. Without
# this the status stays on the stale bundled error even though
# that's no longer the active failure.
_both_failed_err = (
f"Both bundled and user-installed copies of '{evicted_id}' "
"failed to load routes; plugin unavailable — check server logs"
)
_mark_failed(evicted_id, _both_failed_err)
_emit_progress(
"plugin-error",
f"Fallback copy of plugin '{evicted_id}' also failed to load routes",
plugin_id=evicted_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
error=_both_failed_err,
)
# Warn on partial registration in the fallback path too.
_fallback_routes_after = len(getattr(app, "routes", []))
if _fallback_routes_after > _fallback_routes_before:
log.warning(
"Fallback copy of %r registered %d route(s) before its setup() raised; "
"these handlers cannot be removed.",
evicted_id, _fallback_routes_after - _fallback_routes_before,
)
fallback_routes_ok = False
if fallback_routes_ok:
ev_entry = dict(_nav_entry(evicted_id, ev_dir, ev_manifest, _bundled_orig_order))
ev_entry.update({
"status": "ready",
# _nav_entry already sets bundled=False for a user copy
# (not in PLUGINS_DIR); mark it as the emergency fallback so
# /api/plugins and the settings UI can warn that the bundled
# build is broken and an older user copy is running.
"fallback": True,
# Capability metadata (standards, capabilities, shims,
# settings_schema, ui_contributions, runtime_domains) and the
# has_* booleans are already carried by _nav_entry() above.
"_export_paths": _normalize_export_paths(ev_manifest.get("settings"), evicted_id),
"_diagnostics_paths": _normalize_diagnostics_paths(ev_manifest.get("diagnostics"), evicted_id),
"_diagnostics_callable_spec": _parse_diagnostics_callable(ev_manifest.get("diagnostics"), evicted_id),
"_load_sibling": ev_context["load_sibling"],
"_dir": ev_dir,
"_manifest": ev_manifest,
})
_graduate(ev_entry)
log.info("Registered fallback user copy of plugin %r (%s)", evicted_id, ev_manifest.get("name", ""))
# Emit a compensating progress event to clear the bundled-failure
# error from startup-status. Without this, the final
# `plugins-complete` status would still carry the error text from
# the bundled failure even though the plugin is now active via the
# fallback copy. Uses clear_error=True so the server handler
# replaces the stale error with null rather than ignoring it.
# Only send clear_error when req install also succeeded; if req
# failed we emitted a plugin-error above and must not clear it —
# the fallback copy is active but degraded (missing dependencies).
_emit_progress(
"plugin-registered",
f"Registered fallback copy of plugin '{evicted_id}'",
plugin_id=evicted_id,
loaded=_loaded_count(),
total=len(plugin_load_specs),
clear_error=ev_req_ok,
)
# No final atomic publish: plugins were published incrementally as they
# graduated (see _graduate). LOADED_PLUGINS now holds exactly the ready
# plugins; any that failed remain visible as "failed" pending entries.
_emit_progress(
"plugins-complete",
f"Loaded {_loaded_count()} plugin(s)",
loaded=_loaded_count(),
total=len(plugin_load_specs),
)
def _check_plugin_update(plugin_dir: Path) -> dict | None:
"""Check if a plugin's git repo has updates available."""
git_dir = plugin_dir / ".git"
if not git_dir.exists():
return None
try:
# Fetch latest from remote (quick, refs only)
subprocess.run(
["git", "fetch", "--quiet"],
cwd=str(plugin_dir), capture_output=True, timeout=15,
)
# Compare local HEAD with remote tracking branch
result = subprocess.run(
["git", "rev-list", "HEAD..@{u}", "--count"],
cwd=str(plugin_dir), capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
behind = int(result.stdout.strip())
# Get current and remote commit hashes
local = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(plugin_dir), capture_output=True, text=True, timeout=5,
).stdout.strip()
remote = subprocess.run(
["git", "rev-parse", "--short", "@{u}"],
cwd=str(plugin_dir), capture_output=True, text=True, timeout=5,
).stdout.strip()
return {"behind": behind, "local": local, "remote": remote}
except Exception:
return None
def register_plugin_api(app: FastAPI):
"""Register the plugin discovery API endpoints."""
@app.get("/api/plugins")
def list_plugins():
# Return the UNION of ready (LOADED_PLUGINS) and not-yet-ready
# (PENDING_PLUGINS) plugins, each carrying a `status` so the nav can
# render every discovered plugin immediately — ready ones active,
# installing/failed ones disabled — instead of waiting for the
# slowest dependency install (issue #421). Rows are re-sorted by their
# discovery order so the nav slot of a still-installing plugin sits
# where it will land once ready, regardless of which structure it's in.
with PLUGINS_LOCK:
loaded = list(LOADED_PLUGINS)
pending = list(PENDING_PLUGINS.values())
rows: list[tuple] = []
for p in loaded:
rows.append((p.get("_order", 0), {
"id": p["id"],
"name": p["name"],
# Surface the manifest's `version` field (free-form
# semver string) so diagnostics bundles + the future
# plugin marketplace can identify exactly which build
# is loaded. None when the plugin omits the field.
"version": (p.get("_manifest") or {}).get("version"),
"nav": p["nav"],
# type is None for plugins without the manifest hint —
# frontend filters like "give me all type=visualization"
# work via identity comparison; absent is treated as
# "no declared role".
"type": p.get("type"),
# v3 Pedalboard footswitch state. `.get(..., True)` keeps stubbed
# test entries (and any pre-feature entry) enabled by default;
# capabilities are suppressed below when this is False.
"enabled": p.get("enabled", True),
# v3 Pedalboard metadata (carried from _nav_entry; `.get()`
# fallbacks keep stubbed test entries working). `icon` was
# resolved at discovery (manifest or assets/thumb.png probe).
"description": p.get("description") if "description" in p else ((p.get("_manifest") or {}).get("description") or None),
"category": p.get("category") if "category" in p else ((p.get("_manifest") or {}).get("category") or None),
"icon": p.get("icon") if "icon" in p else ((p.get("_manifest") or {}).get("icon") or None),
# `bundled` is reserved metadata flagging plugins that
# ship with the default container image (feedBack#160).
# Surfaced in /api/plugins so the plugin-list UI can
# render a "Bundled" badge (lock icon) next to the
# plugin name in the settings collapsible.
"bundled": p.get("bundled", False),
# `fallback` is True only for user-installed copies that
# are active because the bundled plugin's routes failed.
# Surfaced in /api/plugins so the settings UI can show
# a warning badge, letting users know the bundled build
# is broken and they are running an older user copy.
"fallback": p.get("fallback", False),
"has_screen": p["has_screen"],
"has_script": p["has_script"],
# Module-migration passthrough (R0). Re-read from the manifest
# like `version` above so stubbed test entries (built without
# _nav_entry) don't need the key.
"script_type": (p.get("_manifest") or {}).get("scriptType"),
"min_host": (p.get("_manifest") or {}).get("minHost"),
"has_settings": p["has_settings"],
# v3 immersive screen opt-in (full-screen plugin UI).
"fullscreen": p.get("fullscreen", False),
# Settings-tab placement; None when the manifest's `settings`
# is absent, a bare string, or omits `category`.
"settings_category": p.get("settings_category"),
"has_tour": p.get("has_tour", False),
# `.get()` fallbacks keep stubbed test entries (built without
# _nav_entry) working — styles is None when unset.
"has_styles": p.get("has_styles", False),
"styles": p.get("styles"),
# capability-pipelines.v1 metadata, computed once by
# _nav_entry() at discovery and carried through graduation.
# The `.get()` fallbacks keep stubbed test entries (which build
# rows directly without going through _nav_entry) working.
"standards": p.get("standards") or _normalize_string_list((p.get("_manifest") or {}).get("standards")),
"capabilities": p["capabilities"] if "capabilities" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("capabilities")),
"capability_validation_warnings": p.get("capability_validation_warnings", []),
"capability_unsupported_versions": p.get("capability_unsupported_versions", []),
"compatibility_shims": p.get("compatibility_shims", _compatibility_shims_from_manifest(p.get("_manifest") or {}, p.get("id", "plugin"))),
# Check key presence (not truthiness) like `capabilities` above:
# the loader may intentionally set these to an empty dict after
# validation/sanitization, and a truthiness `or` would wrongly
# re-derive legacy/runtime-domain data from the raw manifest.
"settings_schema": p["settings_schema"] if "settings_schema" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("settings_schema")),
"ui_contributions": p["ui_contributions"] if "ui_contributions" in p else _normalize_ui_contributions(p.get("_manifest") or {}),
"runtime_domains": p["runtime_domains"] if "runtime_domains" in p else _normalize_runtime_domains(p.get("_manifest") or {}),
# Anything in LOADED_PLUGINS is ready by construction.
"status": "ready",
"error": None,
}))
for e in pending:
rows.append((e.get("_order", 0), {
"id": e["id"],
"name": e["name"],
"version": e.get("version"),
"nav": e.get("nav"),
"type": e.get("type"),
# v3 Pedalboard footswitch state — a startup-disabled plugin sits
# in PENDING_PLUGINS with status "disabled"; capabilities are
# suppressed below when this is False.
"enabled": e.get("enabled", True),
# v3 Pedalboard metadata — pending entries come from _nav_entry
# too, so they already carry these.
"description": e.get("description"),
"category": e.get("category"),
"icon": e.get("icon"),
"bundled": e.get("bundled", False),
# A pending plugin is never an active fallback (the fallback
# only exists once a user copy has graduated to ready).
"fallback": False,
"has_screen": e.get("has_screen", False),
"has_script": e.get("has_script", False),
# Pending entries come from _nav_entry, so they carry these.
"script_type": e.get("script_type"),
"min_host": e.get("min_host"),
"has_settings": e.get("has_settings", False),
"settings_category": e.get("settings_category"),
"fullscreen": e.get("fullscreen", False),
"has_tour": e.get("has_tour", False),
"has_styles": e.get("has_styles", False),
"styles": e.get("styles"),
# Pending entries are built from _nav_entry() too, so they
# carry the same capability-pipelines.v1 metadata — surface it
# so the Inspector can show still-installing plugins.
"standards": e.get("standards", []),
"capabilities": e.get("capabilities", {}),
"capability_validation_warnings": e.get("capability_validation_warnings", []),
"capability_unsupported_versions": e.get("capability_unsupported_versions", []),
"compatibility_shims": e.get("compatibility_shims", []),
"settings_schema": e.get("settings_schema", {}),
"ui_contributions": e.get("ui_contributions", {}),
"runtime_domains": e.get("runtime_domains", {}),
# "installing" while its deps install; "failed" if the install
# or route load failed (the nav entry stays, disabled, with
# the error text in `error`).
"status": e.get("status", "installing"),
"error": e.get("error"),
}))
# Stable sort by discovery order. Stubbed test entries default to 0 and
# keep their insertion order (loaded before pending) under a stable sort.
rows.sort(key=lambda r: r[0])
# Final pass: a disabled plugin (startup-skipped or runtime-toggled-off)
# must not occupy the capability pipeline graph.
return [_suppress_capabilities_for_disabled(row) for _, row in rows]
@app.get("/api/plugins/updates")
def check_updates():
"""Check all plugins for available git updates."""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
updates = {}
for p in snapshot:
info = _check_plugin_update(p["_dir"])
if info and info["behind"] > 0:
updates[p["id"]] = {
"name": p["name"],
"behind": info["behind"],
"local": info["local"],
"remote": info["remote"],
}
return {"updates": updates}
@app.post("/api/plugins/{plugin_id}/update")
def update_plugin(plugin_id: str):
"""Pull latest changes for a plugin. Stashes local edits first."""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
git_dir = p["_dir"] / ".git"
if not git_dir.exists():
return {"error": "Not a git repository"}
cwd = str(p["_dir"])
try:
# Stash any local modifications so pull doesn't fail
subprocess.run(
["git", "stash", "--quiet"],
cwd=cwd, capture_output=True, timeout=10,
)
result = subprocess.run(
["git", "pull", "--ff-only"],
cwd=cwd, capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
# Restore stash on failure
subprocess.run(
["git", "stash", "pop", "--quiet"],
cwd=cwd, capture_output=True, timeout=10,
)
return {"error": result.stderr[:500]}
return {"ok": True, "message": result.stdout.strip()}
except Exception as e:
return {"error": str(e)}
return {"error": "Plugin not found"}
@app.post("/api/plugins/{plugin_id}/enabled")
async def set_plugin_enabled(plugin_id: str, request: Request):
"""Enable or disable a plugin (v3 Pedalboard footswitch).
Body: ``{"enabled": <bool>}``. Returns ``{"id", "enabled"}``.
Runtime semantics: the choice is persisted to
``CONFIG_DIR/plugin_state.json`` immediately and the in-memory entry's
`enabled` flag is flipped so the very next ``/api/plugins`` (and thus the
nav, the Pedalboard, and the capability pipeline) reflects it at once. A
plugin disabled at runtime keeps its already-mounted routes/screen until
the next restart — full hot-unload is out of scope; the frontend treats
``enabled: false`` as "off" regardless. Re-enabling a plugin that was
skipped at startup updates the flag immediately but the plugin only
actually mounts on the next restart.
"""
# Async handler (unlike the sync handlers around it) so we can await the
# raw JSON body — this file has no Pydantic models and we want explicit
# 400/404 status codes rather than FastAPI's 422 validation envelope.
try:
data = await request.json()
except Exception:
data = None
if not isinstance(data, dict) or "enabled" not in data or not isinstance(data["enabled"], bool):
# `bool` is an `int` subclass, so isinstance(..., bool) is required to
# reject 0/1/ints/strings and demand a real boolean.
return JSONResponse({"error": "Body must include boolean 'enabled'"}, status_code=400)
enabled = data["enabled"]
# The plugin must exist (ready or pending) to be toggled. Checked BEFORE
# the always-on guard so an unknown id always 404s regardless of how it
# is spelled (an unknown `app_tour_*` id is "not found", not "cannot be
# disabled").
with PLUGINS_LOCK:
known = any(p["id"] == plugin_id for p in LOADED_PLUGINS) or plugin_id in PENDING_PLUGINS
if not known:
return JSONResponse({"error": "Plugin not found"}, status_code=404)
# Guard rail: a small always-on set may never be disabled (disabling
# would brick the app or the capability-graph review surface).
if not enabled and _is_always_enabled(plugin_id):
return JSONResponse(
{"error": f"Plugin '{plugin_id}' cannot be disabled"},
status_code=400,
)
# Persist first; only flip the in-memory flag if the write succeeded so
# memory and disk never diverge. A write failure (unwritable CONFIG_DIR,
# disk full, replace failure) is a controlled 500, not an uncaught crash.
try:
_persist_plugin_enabled(plugin_id, enabled)
except OSError as e:
log.warning("Could not persist plugin state for %r: %s", plugin_id, e)
return JSONResponse(
{"error": "Could not persist plugin state"}, status_code=500
)
# Flip the in-memory flag so the next /api/plugins reflects the change
# without a restart (routes stay mounted until restart regardless).
with PLUGINS_LOCK:
for p in LOADED_PLUGINS:
if p["id"] == plugin_id:
p["enabled"] = enabled
pend = PENDING_PLUGINS.get(plugin_id)
if pend is not None:
pend["enabled"] = enabled
return {"id": plugin_id, "enabled": enabled}
@app.get("/api/plugins/{plugin_id}/screen.html")
def plugin_screen_html(plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
screen_file = p["_dir"] / p["_manifest"].get("screen", "screen.html")
if screen_file.exists():
return HTMLResponse(screen_file.read_text(encoding="utf-8"))
return HTMLResponse("", status_code=404)
@app.get("/api/plugins/{plugin_id}/screen.js")
def plugin_screen_js(request: Request, plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
if script_file.is_file():
# no-cache + ETag/304 so an edited screen.js reloads on
# refresh while an unchanged one revalidates cheaply — the
# same live-edit contract the src/ module graph relies on.
return _plugin_file_response(request, script_file, "application/javascript")
return Response("", status_code=404)
# ── Module-graph cache busting (#879) ────────────────────────────────
#
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
# <script type="module"> whose src the module map has already seen fires
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
# and (see below) an upgrade too — silently kept the OLD module live while
# the loader recorded success: a no-op that reported it worked.
#
# Busting the ENTRY url does not help. A module plugin's screen.js is a
# one-line `import './src/main.js'`, and a relative specifier resolves
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
# the graph. Driving a real browser through install -> upgrade -> rollback and
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
# same URL; the module map returns the already-evaluated old module.
#
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
# relative import inherits it at every depth — for free, with no
# import-specifier rewriting (which could never see `import(expr)` anyway).
#
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
# URL, so EVERYTHING a module resolves relatively moves with it — not just
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
# worklet and wasm file the graph reaches — and would silently break again the
# next time someone adds a plugin route. Stripping the segment before routing
# makes every plugin route, present and future, work under the prefix.
#
# The token is opaque: it is never joined into a filesystem path (and is gone
# by the time any handler runs), so containment still rests entirely on the
# same safe_join the un-prefixed routes use.
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
@app.middleware("http")
async def _strip_plugin_generation_prefix(request: Request, call_next):
m = _GEN_PREFIX.match(request.scope.get("path", ""))
if m:
# Starlette routes on scope["path"] alone. raw_path is deliberately left
# ALONE: it is informational, and re-encoding the rewritten str back to
# bytes would have to guess a codec — `.encode("latin-1")` raises
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
# the client actually sent it is also simply more truthful for logs.
request.scope["path"] = m.group(1) + m.group(2)
return await call_next(request)
@app.get("/api/plugins/{plugin_id}/settings.html")
def plugin_settings_html(plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
settings = p["_manifest"].get("settings", {})
settings_file = p["_dir"] / (settings.get("html", "settings.html") if isinstance(settings, dict) else "settings.html")
if settings_file.exists():
return HTMLResponse(settings_file.read_text(encoding="utf-8"))
return HTMLResponse("", status_code=404)
@app.get("/api/plugins/{plugin_id}/tour.json")
def plugin_tour_json(plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
tour_val = p["_manifest"].get("tour")
if not _is_valid_tour_manifest(tour_val):
break
if isinstance(tour_val, str):
tour_filename = tour_val
elif isinstance(tour_val, dict):
tour_filename = tour_val.get("file", "tour.json")
else:
break # shouldn't reach here; _is_valid_tour_manifest guards above
# Quick pre-filter for obvious bad paths. This is not the
# authoritative security boundary — the resolve+relative_to
# check below is — but catching simple cases early produces
# a cleaner log message before the filesystem calls.
if (
not isinstance(tour_filename, str)
or not tour_filename
or ".." in tour_filename.split("/")
or tour_filename.startswith("/")
or "\\" in tour_filename
):
log.warning("Plugin %r: invalid tour path rejected: %r", plugin_id, tour_filename)
break
tour_file = (p["_dir"] / tour_filename).resolve()
plugin_dir = p["_dir"].resolve()
# Ensure resolved path stays inside the plugin directory
try:
tour_file.relative_to(plugin_dir)
except ValueError:
log.warning("Plugin %r: tour path escapes plugin dir: %r", plugin_id, tour_filename)
break
if tour_file.is_file():
return Response(tour_file.read_text(encoding="utf-8"), media_type="application/json")
break
return Response("{}", status_code=404, media_type="application/json")
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
"""Serve a static file a plugin bundles under its own ``assets/``
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
fixed screen.js/settings.html handlers above, this is a generic
subtree so plugins can self-host arbitrary assets — required because
the browser must fetch them by URL (no CDN, per the constitution).
Containment is enforced by ``safe_join`` against ``<plugin>/assets``,
so ``..`` traversal, absolute paths, and NUL bytes cannot reach the
plugin's Python modules or anything outside ``assets/``.
"""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
target = safe_join(p["_dir"] / "assets", asset_path)
if target is None:
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
break
if target.is_file():
# no-cache + ETag/304 so a live-edited worklet/asset reloads
# on refresh (bare FileResponse emits an ETag but never 304s).
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
def plugin_src(request: Request, plugin_id: str, src_path: str):
"""Serve a file from a plugin's ES-module source tree under ``src/``.
This is the R0 host capability that lets a migrated plugin's
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
module graph. Containment mirrors the assets/ route exactly —
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
makes an edited module reload on refresh while unchanged ones 304.
Read-only; the src/ tree is source files, never executed server-side.
"""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
target = safe_join(p["_dir"] / "src", src_path)
if target is None:
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
break
if target.is_file():
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)