Skip to content

feat(web): add entry-point–based extension layer for the configure UI#127

Merged
hyoshi merged 5 commits into
mainfrom
feat/p1-12-web-extensions
May 21, 2026
Merged

feat(web): add entry-point–based extension layer for the configure UI#127
hyoshi merged 5 commits into
mainfrom
feat/p1-12-web-extensions

Conversation

@hyoshi
Copy link
Copy Markdown
Collaborator

@hyoshi hyoshi commented May 21, 2026

Summary

Adds a new entry-point group mureo.web_extensions that lets a third-party plugin register additional tabs / API routes inside the mureo configure wizard. The mechanism mirrors the existing mureo.providers / mureo.runtime_context_factory extension patterns (per-plugin fault isolation, first-wins on duplicates, *Warning subclass for strict-mode opt-in) and extends the same Phase 1 extensibility story into the web layer.

Why

mureo/web/handlers.py was the last surface where third-party plugins could not contribute UI / API routes. The OSS provider layer, skill matcher, and RuntimeContext resolver are already entry-point–driven; this PR closes the gap so plugins shipping alternate SecretStore / StateStore backends — or any custom data source — can add their own setup UI inside the same mureo configure wizard the operator already uses.

Public surface added to mureo.web.extensions

from mureo.web.extensions import (
    WebExtension,                       # @runtime_checkable Protocol
    WebExtensionEntry,                  # frozen dataclass (discovery result)
    RouteContribution, ViewContribution, StaticAsset,
    WebExtensionWarning,                # subclass of UserWarning
    WEB_EXTENSIONS_ENTRY_POINT_GROUP,   # "mureo.web_extensions"
    NAME_PATTERN, FILENAME_PATTERN, SUBPATH_PATTERN,  # shared regex strings
    discover_web_extensions,            # cached entry-point scan
    reset_web_extensions,               # test helper
)

HTTP surface (mureo/web/handlers.py)

URL Behaviour
GET /api/extensions Index for the front-end renderer (one entry per registered 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).
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 same Content-Security-Policy + X-Frame-Options + Cache-Control header stack.

Front-end (mureo/_data/web/extensions.js)

The configure UI fetches /api/extensions once when the dashboard opens, renders one nav tab per extension, and lazy-loads each extension's html_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: Set dedup.

Security posture

  • RouteContribution.subpath, StaticAsset.filename, and the extension name are regex-validated at both registration (_NAME_RE / _FILENAME_RE / _SUBPATH_RE) and dispatch (_EXTENSION_API_RE / _EXTENSION_STATIC_RE). The two layers share NAME_PATTERN / SUBPATH_PATTERN / FILENAME_PATTERN constants 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 generic not_found branch instead of reaching the extension lookup.
  • Static asset bodies stay in memory; the dispatcher never reads from disk so filesystem traversal is impossible by construction.
  • ViewContribution.html_fragment is rejected at registration 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 runtime enforcement, so HTML-entity-encoded bypasses are blocked anyway.
  • Handler exceptions are caught by _dispatch_extension and surfaced as {"error": "extension_handler_error"} 500 envelopes; the exception repr is 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, sample WebExtension, URL surface table, security model, lazy-load behaviour, debugging recipe).

Commits

Hash Title
b076106 feat(web): add entry-point–based extension layer (types + discovery)
dc443e2 feat(web): mount web extensions in the configure server
f0715e1 feat(web): render extension tabs in the configure UI + plugin-authoring §13
cf33c58 docs(web): document _initialised exception-path design in extensions.js
8bc043b docs(changelog): record web extensions feature under [Unreleased]

Backward compatibility

When no mureo.web_extensions entry points are installed:

  • discover_web_extensions() returns an empty tuple.
  • GET /api/extensions returns [].
  • The renderer's init() filters to [] and creates zero DOM nodes.
  • The configure UI is byte-identical to v0.9.4.

825 existing tests/test_web_* tests pass unchanged.

Test plan

  • CI lint job (ruff check mureo/, black --check mureo/) — clean locally.
  • CI test jobs (Python 3.10 / 3.11 / 3.12 + Windows) — 71 new tests in tests/test_web_extensions.py (45) + tests/test_web_extension_routing.py (26) all pass locally.
  • CI test job covers tests/test_web_handlers.py etc. unchanged — no regressions in the broader 824-test web suite.
  • CodeQL — no new findings expected; net additions are pure-Python + plain JS, no eval / Function(...) / subprocess / shell.
  • Manual: launch mureo configure with no mureo.web_extensions entry points → confirm the dashboard renders exactly as v0.9.4 (no extra tabs, no /api/extensions errors in DevTools).

hyoshi added 5 commits May 21, 2026 18:47
`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 hyoshi merged commit d31a8ff into main May 21, 2026
9 checks passed
@hyoshi hyoshi deleted the feat/p1-12-web-extensions branch May 21, 2026 10:50
@hyoshi hyoshi mentioned this pull request May 21, 2026
3 tasks
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant