feat(web): add entry-point–based extension layer for the configure UI#127
Merged
Conversation
`mureo.web.extensions` lets a third-party distribution register an
entry point in the `mureo.web_extensions` group whose target satisfies
the new `WebExtension` Protocol; downstream commits will mount the
discovered routes / static assets under `/api/ext/<name>/...` /
`/static/ext/<name>/<file>` inside `ConfigureHandler`. This commit is
purely additive — no existing module imports the new layer yet, so
behaviour is unchanged for every current caller.
Design mirrors `mureo.core.providers.registry`:
* per-plugin try/except around the full load → introspect → validate
pipeline (a broken plugin cannot break discovery for the rest)
* `WebExtensionWarning(UserWarning)` for opt-in strict mode via
`warnings.filterwarnings("error", category=WebExtensionWarning)`
* first-wins on duplicate names (deterministic; a later plugin cannot
silently take an earlier slot)
* `source_distribution` extracted from `ep.dist.name` defensively
(non-`str` values coerced to `None`)
* result cached for the process lifetime — entry points are populated
by `pip install` and do not change at runtime
Public surface:
* `WEB_EXTENSIONS_ENTRY_POINT_GROUP: Final[str] = "mureo.web_extensions"`
* `WebExtensionWarning` (subclass of `UserWarning`)
* `@dataclass(frozen=True) StaticAsset(filename, content_type, body: bytes)`
* `@dataclass(frozen=True) RouteContribution(method, subpath, handler)`
* `@dataclass(frozen=True) ViewContribution(html_fragment, scripts, styles)`
* `@runtime_checkable Protocol WebExtension`
(`name`, `display_name`, `routes()`, `view()`)
* `@dataclass(frozen=True) WebExtensionEntry(name, display_name, routes,
view, source_distribution)`
* `discover_web_extensions() -> tuple[WebExtensionEntry, ...]` (cached)
* `reset_web_extensions() -> None` (clears the cache; intended for tests)
Security posture:
* `RouteContribution.subpath` regex-validated so `..`, double-slash,
trailing slash, `?`, `#`, and the empty path cannot smuggle the
dispatcher outside `/api/ext/<name>/`.
* `StaticAsset.filename` regex-validated the same way; bodies stay in
memory so filesystem traversal is impossible by construction.
Internal dots are permitted (`app.min.js`, `vendor.bundle.js`,
`i18n.en-us.json`) for modern JS toolchain compatibility.
* `ViewContribution.html_fragment` is rejected at discovery if it
contains `<script>`, `<style>`, `on*=` event handlers, or
`javascript:` URLs. This is an author-feedback signal — the
configure-UI CSP (`script-src 'self'; style-src 'self'`) is the
actual enforcement, so HTML-entity-encoded bypasses that skip the
regex are blocked at runtime.
Tests: 45 unit tests covering Protocol shape, dataclass freezing,
regex validation (positive + negative cases for `_NAME_RE`,
`_FILENAME_RE`, `_SUBPATH_RE`), HTML-fragment sanitisation,
entry-point discovery (0 / 1 / N entries, broken `ep.load()`,
`routes()` / `view()` raising, duplicate-name first-wins, invalid
name skipped, cache reuse, `reset_web_extensions` clears cache).
ruff and black clean on `mureo/`; no existing tests touched.
Wire the `mureo.web.extensions` layer from `b076106` into the
`ConfigureWizard` lifecycle and the `ConfigureHandler` dispatch table.
A third-party plugin registered in the `mureo.web_extensions`
entry-point group is now reachable from the configure UI without
each surface having to know about the plugin individually:
* `GET /api/extensions` — index for the front-end renderer (one entry
per extension; `view` is `null` for headless / route-only plugins)
* `GET /api/ext/<name>/<subpath>` — extension GET route. Payload is
the flattened query string (first-value-wins); multi-value needs
`request.path` directly.
* `POST /api/ext/<name>/<subpath>` — extension POST route, gated by
the existing Host + body-cap + CSRF pipeline (the plugin author
inherits CSRF protection for free).
* `GET /static/ext/<name>/<filename>` — extension-shipped static
asset, served from in-memory bytes with the standard CSP /
X-Frame-Options / Cache-Control header stack.
Wiring:
* `mureo.web.server.ConfigureWizard.__init__` calls
`discover_web_extensions()` once and stores the tuple as
`self.extensions`. The function is cached internally so a second
call within the same process re-uses the same tuple — see
`mureo.web.extensions`.
* `mureo.web.handlers` gains four module-level lookup helpers
(`_find_extension`, `_find_extension_route`, `_find_extension_static`,
`_flatten_query`) plus five `ConfigureHandler` methods
(`_serve_extensions_index`, `_serve_extension_static`,
`_dispatch_extension_get`, `_dispatch_extension_post`,
`_dispatch_extension`).
Regex de-duplication:
* `mureo.web.extensions` now exports `NAME_PATTERN`, `FILENAME_PATTERN`,
`SUBPATH_PATTERN` as bare-string `Final[str]` constants. The
registration-side `_NAME_RE` / `_FILENAME_RE` / `_SUBPATH_RE` and
the dispatch-side `_EXTENSION_API_RE` / `_EXTENSION_STATIC_RE`
are now built from the same source; a future tweak to one pattern
propagates through both layers in lockstep.
Per-handler fault isolation:
* `_dispatch_extension` wraps the handler call in a try/except and
logs `logger.exception(...)` with the extension + subpath context.
Clients receive a generic `{"error": "extension_handler_error"}`
500 envelope; we deliberately do not echo the exception `repr`
because it may carry secrets the handler touched.
* The 500 envelope write itself is guarded by an inner try/except —
if the handler already wrote a partial response before raising,
appending a second status line would violate HTTP framing. We
downgrade the second failure to a debug log; the contract
documented in `mureo.web.extensions` is "handler must not raise
after starting to write the response."
Tests:
* `tests/test_web_extension_routing.py` — 26 new tests that boot a
real `ConfigureWizard` with a pre-seeded `_cached_entries` tuple
and hit every dispatch branch via `urllib.request`. Coverage:
index (empty / non-empty / `view=null`), GET dispatch (first-value
query flattening / unknown extension / unknown subpath / 5 traversal
patterns / handler exception → 500), POST dispatch (CSRF success /
missing / wrong token / GET-only subpath returns 404), static asset
(correct content-type, unknown filename, 4 traversal patterns,
CSP / X-Frame-Options inheritance).
Backward compatibility: 244 existing `tests/test_web_*` tests pass
unchanged; `ConfigureWizard.__init__` adds an internal attribute only
(no new constructor arguments). ruff and black clean on `mureo/`.
…ng §13 Front-end half of the web-extension feature plus the public author guide. Backend dispatch already landed in dc443e2; this commit makes the registered extensions visible to the user and documents the contract. `mureo/_data/web/extensions.js` (new): * `MUREO.extensions.init()` fetches `/api/extensions` once per dashboard session, filters out headless / route-only entries, and appends one `<li>` + empty `<div class="dashboard-group">` per extension to the existing dashboard nav. The `_initialised` flag is set synchronously at the top of `init()` so concurrent `show()` calls collapse to a single fetch. * First click on an extension tab populates its group: `html_fragment` is injected via `innerHTML` (defence-in-depth: the registration-side regex already refused inline `<script>` / `<style>` / `on*=` / `javascript:`, the HTML5 spec bars `innerHTML`-installed `<script>` from executing, and CSP `script-src 'self'; style-src 'self'` blocks any residue). * `_injectScripts` / `_injectStyles` create `<script src="/static/ext/ <name>/<file>" defer>` and `<link rel="stylesheet" ...>` elements with a per-extension/filename element id so re-population is a no-op. The `_populated: Set` prevents double-`innerHTML` write on repeated tab clicks. * `_selectGroup` mirrors `dashboard.js`'s `selectNavGroup` contract (visibility toggle + `aria-current` + re-run-button display). Sharing the helper is a follow-up clean-up (intentional duplication for now because the extension path is opt-in and the shape is small). * Failed fetches / non-OK responses / invalid JSON surface as `console.warn` so operators investigating "why doesn't my tab appear" can see the error in DevTools without affecting other configure-UI features. Wiring: * `mureo/_data/web/app.html` — one new `<script src="/static/extensions.js">` tag at the tail of the script list (loads after `dashboard.js` so `MUREO.extensions` reaches `MUREO.dashboard.show` already populated). * `mureo/_data/web/dashboard.js` — `show()` calls `MUREO.extensions.init()` behind a `typeof ... === "function"` guard. The guard handles the case where `extensions.js` failed to load (e.g., 404) without throwing. * `mureo/web/handlers.py` — `_STATIC_ALLOWLIST` gains `"extensions.js"` so `/static/extensions.js` is served by the existing static branch. Extension-shipped JS reaches the browser via `/static/ext/<name>/<file>` (`_EXTENSION_STATIC_RE`); the allowlist governs only the bundled `mureo/_data/web/` files. `docs/plugin-authoring.md` (new §13): * When-to-use guidance pointing plugin authors at the right surface (web extensions for the configure UI; provider Protocols + skills for runtime tools). * End-to-end example: `WebExtension` class, `pyproject.toml` entry-point block (`mureo.web_extensions`), URL surface table. * Security model section covering CSP / CSRF / fault isolation — every claim cross-checked against the implementation in `mureo/web/extensions.py` and `mureo/web/handlers.py`. * Lazy-load contract, debugging recipe (`pip show`, `importlib.metadata.entry_points`, `WebExtensionWarning`, `warnings.filterwarnings("error", ...)`), and the `reset_web_extensions()` recipe for test fixtures. Backward compatibility: when no extensions are registered, `/api/extensions` returns `[]`, `init()` filters to `[]`, zero DOM mutations occur, and the configure UI is byte-identical to before. 825 existing web tests pass; ruff and black clean on `mureo/`.
Adds three comment lines noting that an exception leaves _initialised true on purpose — failed discovery requires a full page reload (not a silent retry) so operators can investigate via the console.warn diagnostics added in f0715e1. Comment-only change; behaviour, tests, and lint outputs are unchanged. Bundles in a re-review nit (L8) of the f0715e1 fix-up commit.
Adds the [Unreleased] section covering the four feat/p1-12-web-extensions commits (b076106, dc443e2, f0715e1, cf33c58): public surface in mureo.web.extensions, four new HTTP routes in ConfigureHandler, front-end renderer in mureo/_data/web/extensions.js, plugin-authoring guide §13, and the security model (regex-validated paths, in-memory static bodies, CSP + CSRF inheritance, fault-isolated dispatch).
hyoshi
added a commit
that referenced
this pull request
May 21, 2026
…128) See CHANGELOG.md for details. Public additions: * mureo.web.extensions — Protocol + 3 frozen dataclasses (RouteContribution, ViewContribution, StaticAsset), discovery helpers (discover_web_extensions / reset_web_extensions), and the shared regex constants (NAME_PATTERN, SUBPATH_PATTERN, FILENAME_PATTERN) used by both registration and dispatch. * HTTP surface in mureo.web.handlers: * GET /api/extensions * GET /api/ext/<name>/<subpath> * POST /api/ext/<name>/<subpath> * GET /static/ext/<name>/<filename> * Entry-point group: mureo.web_extensions * Front-end renderer: mureo/_data/web/extensions.js lazy-loads each extension's html_fragment / scripts / styles on first tab click. * docs/plugin-authoring.md §13 documents the contract end-to-end. Version bumped to 0.9.5 across .claude-plugin/plugin.json, mureo/__init__.py, and pyproject.toml.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new entry-point group
mureo.web_extensionsthat lets a third-party plugin register additional tabs / API routes inside themureo configurewizard. The mechanism mirrors the existingmureo.providers/mureo.runtime_context_factoryextension patterns (per-plugin fault isolation, first-wins on duplicates,*Warningsubclass for strict-mode opt-in) and extends the same Phase 1 extensibility story into the web layer.Why
mureo/web/handlers.pywas the last surface where third-party plugins could not contribute UI / API routes. The OSS provider layer, skill matcher, andRuntimeContextresolver are already entry-point–driven; this PR closes the gap so plugins shipping alternateSecretStore/StateStorebackends — or any custom data source — can add their own setup UI inside the samemureo configurewizard the operator already uses.Public surface added to
mureo.web.extensionsHTTP surface (
mureo/web/handlers.py)GET /api/extensionsviewisnullfor headless / route-only plugins).GET /api/ext/<name>/<subpath>POST /api/ext/<name>/<subpath>GET /static/ext/<name>/<filename>Front-end (
mureo/_data/web/extensions.js)The configure UI fetches
/api/extensionsonce when the dashboard opens, renders one nav tab per extension, and lazy-loads each extension'shtml_fragment/<script>/<link rel="stylesheet">on the first tab activation. Operators who never visit a given tab pay zero added page weight; subsequent visits short-circuit via a_populated: Setdedup.Security posture
RouteContribution.subpath,StaticAsset.filename, and the extensionnameare regex-validated at both registration (_NAME_RE/_FILENAME_RE/_SUBPATH_RE) and dispatch (_EXTENSION_API_RE/_EXTENSION_STATIC_RE). The two layers shareNAME_PATTERN/SUBPATH_PATTERN/FILENAME_PATTERNconstants so they cannot drift..., double-slash, trailing slash,?,#, directory separators, leading dots, and uppercase are all refused so a crafted URL falls through to the genericnot_foundbranch instead of reaching the extension lookup.ViewContribution.html_fragmentis rejected at registration if it contains<script>,<style>,on*=event handlers, orjavascript:URLs. This is an author-feedback signal — the configure-UI CSP (script-src 'self'; style-src 'self') is the actual runtime enforcement, so HTML-entity-encoded bypasses are blocked anyway._dispatch_extensionand surfaced as{"error": "extension_handler_error"}500 envelopes; the exceptionrepris logged server-side only (it may carry secrets the handler touched). A handler that already wrote a partial response before raising is doubly-guarded so the configure server never tries to append a second status line to a half-shipped response.Plugin author docs
docs/plugin-authoring.md§13 documents the contract end-to-end (entry-point setup, sampleWebExtension, URL surface table, security model, lazy-load behaviour, debugging recipe).Commits
b076106feat(web): add entry-point–based extension layer (types + discovery)dc443e2feat(web): mount web extensions in the configure serverf0715e1feat(web): render extension tabs in the configure UI + plugin-authoring §13cf33c58docs(web): document _initialised exception-path design in extensions.js8bc043bdocs(changelog): record web extensions feature under [Unreleased]Backward compatibility
When no
mureo.web_extensionsentry points are installed:discover_web_extensions()returns an empty tuple.GET /api/extensionsreturns[].init()filters to[]and creates zero DOM nodes.825 existing
tests/test_web_*tests pass unchanged.Test plan
ruff check mureo/,black --check mureo/) — clean locally.tests/test_web_extensions.py(45) +tests/test_web_extension_routing.py(26) all pass locally.tests/test_web_handlers.pyetc. unchanged — no regressions in the broader 824-test web suite.eval/Function(...)/ subprocess / shell.mureo configurewith nomureo.web_extensionsentry points → confirm the dashboard renders exactly as v0.9.4 (no extra tabs, no/api/extensionserrors in DevTools).