From b2bcd659c17e8b03717455b4aa231d241ec33c7d Mon Sep 17 00:00:00 2001 From: Ouroboros Date: Wed, 2 Sep 2026 17:22:55 +0300 Subject: [PATCH] gateway: passive GET /api/widgets, discovery-free module endpoint, drop dead ui_tabs_pending - New ouroboros/gateway/widgets.py: GET /api/widgets projects live UI tabs from the in-memory extension snapshot only (no discover_skills, no stale-review reconcile, no schedule sync, no hashing, no writes). Each card carries the owning skill's live payload content_hash as `revision` -- a revision fact for the page's change signature, not an ETag. Wired through router, endpoint_index, api_client (`apiClient.widgets`), api_types (WidgetTab, WidgetsResponse) and the parity/smoke suites. The Widgets page itself still reads GET /api/extensions; the frontend switch is a later phase. - api_extension_module: authorization is the live loader state alone. `extension_loader.live_bundle_facts` resolves the reviewed payload directory from the loaded bundle and the live tab registration must still declare exactly this entry, so both discover_skills walks leave this hot read path. Adds `Access-Control-Allow-Origin: *` (the opaque-origin module frame fetches anonymously cross-origin); `Cache-Control: no-store` stays. An unloaded skill now answers 409 "not live" before the 404 entry check. - Remove the dead two-phase fields `ui_host_pending` / `ui_tabs_pending` (always True / always []; no frontend, CLI, or colab reader) and their test pins; type the `live` block of GET /api/extensions as ExtensionLiveSnapshot (homed in gateway/widgets.py to keep contracts.py in its size band). - ARCHITECTURE.md: module tree entry, endpoint rows, Skills and Widgets rationale for the passive read and the CORS header. - tests/test_gateway_widgets.py pins zero discovery/reconcile/sync/hash calls on both read paths and the exact WidgetTab shape. No version bump: contribution branch, release carriers byte-identical to the base. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- docs/ARCHITECTURE.md | 8 +- ouroboros/extension_loader.py | 17 +- ouroboros/gateway/contracts.py | 3 + ouroboros/gateway/endpoint_index.py | 1 + ouroboros/gateway/extensions.py | 50 ++---- ouroboros/gateway/router.py | 2 + ouroboros/gateway/widgets.py | 85 +++++++++ tests/test_extension_surfaces.py | 1 - tests/test_extensions_api.py | 5 +- tests/test_gateway_parity.py | 6 +- tests/test_gateway_smoke.py | 5 + tests/test_gateway_widgets.py | 165 ++++++++++++++++++ tests/test_marketplace_provenance_contract.py | 1 - tests/test_ouroboroshub_adopt.py | 2 +- web/modules/api_client.js | 7 + web/modules/api_types.js | 23 +++ 16 files changed, 337 insertions(+), 44 deletions(-) create mode 100644 ouroboros/gateway/widgets.py create mode 100644 tests/test_gateway_widgets.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1520905a..ffa17555a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -296,6 +296,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de │ ├── ui_preferences.py ← owner-local UI preferences (`state/ui_preferences.json`): widget order, nested subagent expansion, and UI defaults │ ├── models.py ← model catalog, request-local provider readiness probes, and local-model lifecycle endpoints │ ├── extensions.py ← extensions/skills HTTP surface (GET /api/extensions, GET /api/extensions//manifest, ALL /api/extensions//, POST /api/skills//toggle, POST /api/skills//delete, POST /api/skills//review, POST /api/skills//grants) + │ ├── widgets.py ← GET /api/widgets: the Widgets page card list projected from the in-memory extension snapshot (live UI tabs + the owning skill's live payload `content_hash` as `revision`) — no skill discovery, stale-review reconcile, schedule sync, hashing, or writes on this read path; also homes the `ExtensionLiveSnapshot` TypedDict that types the `live` block of GET /api/extensions (kept out of contracts.py for its size band) │ ├── skill_publish.py ← Selected-skill read-only publication preflight: unique candidate capture, current review-staleness projection, safe scanner cache, and one backend-owned five-state response; creates no task and performs no GitHub effect │ ├── marketplace.py ← ClawHub + OuroborosHub HTTP surface │ ├── mcp.py ← MCP Settings API surface backed by the shared MCPManager @@ -802,11 +803,11 @@ Skills has three views: installed skills, ClawHub, and OuroborosHub. Marketplace Installation, deterministic preflight, LLM review, owner grants, dependency readiness, extension loading, enablement, and execution are separate lifecycle facts. A fresh executable review does not imply that requested keys were granted or dependencies installed, and `enabled=true` does not override a blocked review or load error. Owner attestation, where eligible, skips only the expensive LLM review; deterministic preflight and the normal post-pass dependency/extension reconciliation still run. Repair creates a real constrained managed task visible in Chat. Hub publication uses the selected-skill preflight and ordinary managed task described under Skills and extensions; the passive Installed projection neither runs Betterleaks nor claims publication readiness. -Widgets is a separate page because extension UI is an execution surface, not catalogue metadata. It renders only UI tabs registered by reviewed live extensions and supports three modes. An extension-route iframe uses an empty sandbox capability set. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe with `allow-scripts` but without `allow-same-origin`. Framed declarations may set a bounded `height` from 320 to 8,192 pixels; a module without `height` starts at the 320-pixel floor and reports its existing `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height` (default 8,192). For module auto-height, the injected host bootstrap owns vertical viewport overflow: below the finite ceiling it suppresses only `overflow-y`, keeping the child's inline-size basis stable while block size is applied; at the ceiling it releases that rule so excess content is vertically reachable. Horizontal document overflow remains author-controlled and reachable. Fixed-height modules receive no host overflow rule; legacy route iframes retain their existing scroll behavior and remain explicit-height-only because their opaque document cannot be measured by the parent. A framed resize protocol is correct only when measurement and application converge to a fixed point. Geometry keys are rejected for declarative renders, which remain content-driven. +Widgets is a separate page because extension UI is an execution surface, not catalogue metadata. It renders only UI tabs registered by reviewed live extensions and supports three modes. The card list has a dedicated passive read, `GET /api/widgets`: a projection of the in-memory extension snapshot in which each card carries the owning skill's live payload `content_hash` as `revision` (a change-signature fact, not an ETag); the Widgets page itself still reads `live.ui_tabs` from `GET /api/extensions`. An extension-route iframe uses an empty sandbox capability set. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe with `allow-scripts` but without `allow-same-origin`. Framed declarations may set a bounded `height` from 320 to 8,192 pixels; a module without `height` starts at the 320-pixel floor and reports its existing `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height` (default 8,192). For module auto-height, the injected host bootstrap owns vertical viewport overflow: below the finite ceiling it suppresses only `overflow-y`, keeping the child's inline-size basis stable while block size is applied; at the ceiling it releases that rule so excess content is vertically reachable. Horizontal document overflow remains author-controlled and reachable. Fixed-height modules receive no host overflow rule; legacy route iframes retain their existing scroll behavior and remain explicit-height-only because their opaque document cannot be measured by the parent. A framed resize protocol is correct only when measurement and application converge to a fixed point. Geometry keys are rejected for declarative renders, which remain content-driven. Declarative widgets support forms and actions, status/data/text/code/markdown, tables, tabs, charts, polls, jobs, streams, subscriptions, progress, media, files, maps, calendars, kanban, and composition through `group`, `metric`, and `callout`. One recursive validator limits the tree to depth 8 and 256 nodes and reports the exact failing path. Nested interactive components use an explicit id or stable tree path as identity; `subscription.render` remains transitively passive so an incoming event cannot smuggle a new active control tree past validation. Text, attributes, links, media routes, and field values are escaped or constrained for their actual sink. -Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions//...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN. +Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions//...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. The module source endpoint authorizes purely against the live loader registration (the skill's loaded bundle declares that entry — no skill discovery on the read path) and answers with `Access-Control-Allow-Origin: *`, because the requesting `srcdoc` frame has an opaque origin and its script fetches are cross-origin and anonymous. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN. A mounted widget owns its timers, abort controllers, chart objects, event streams, polls, jobs, and WebSocket message handlers through one disposer. Leaving Widgets or forcing a refresh disposes the mounted work, removes framed iframes, aborts host bridge requests, and ignores late resize/fetch messages; an async mount that finishes after the page generation changed disposes itself instead of registering a hidden frame. A later visit may repaint the last good extension payload and restore bounded widget session state without leaving the hidden copy running. Job polling keeps its `job_id` across bounded retryable transport/408/429/5xx failures and request timeouts, while explicit terminal job states remain terminal. A missing or malformed status envelope fails immediately; a non-empty producer-specific in-progress status remains pending but is still bounded by `max_ticks`. The existing interval and tick bounds remain the scheduler rather than a second polling service. Poll and WebSocket writers use monotonic progress for one job so an older response cannot rewind a newer event. Transient refresh failure preserves the last good widgets instead of blanking the page. Card order is keyboard- and drag-adjustable owner UI state stored through `/api/ui/preferences`; it never rewrites the extension manifest or changes the review boundary. @@ -872,9 +873,10 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera | GET | `/api/health` | `gateway.state.api_health` | | GET | `/api/state` | `gateway.state.api_state` | | GET | `/api/extensions` | `gateway.extensions.api_extensions_index` (unique rows additionally carry `content_hash`, `published` (validated receipt object or null), `published_malformed`; identity-collision rows carry `identity_collision: true` and omit the receipt fields) | +| GET | `/api/widgets` | `gateway.widgets.api_widgets` (passive projection of `extension_loader.snapshot()` UI tabs; each card carries the owning skill's live payload `content_hash` as `revision` — a change-signature fact for the page, not an ETag) | | POST | `/api/skills/{skill}/publish-preflight` | `gateway.skill_publish.api_skill_publish_preflight` | | GET | `/api/extensions/{skill}/manifest` | `gateway.extensions.api_extension_manifest` | -| GET | `/api/extensions/{skill}/module/{entry}` | `gateway.extensions.api_extension_module` | +| GET | `/api/extensions/{skill}/module/{entry}` | `gateway.extensions.api_extension_module` (authorizes against the live loader registration only — the loaded bundle's module tab must declare exactly this entry, no skill discovery; answers `Cache-Control: no-store` plus `Access-Control-Allow-Origin: *` because the requesting module frame has an opaque origin and fetches anonymously cross-origin) | | GET | `/api/extensions/{skill}/settings_section` | `gateway.extensions.api_extension_settings_section` | | ANY | `/api/extensions/{skill}/{rest:path}` | `gateway.extensions.api_extension_dispatch` | | GET | `/api/skills/daemons` | `gateway.extensions.api_skill_daemons` | diff --git a/ouroboros/extension_loader.py b/ouroboros/extension_loader.py index b19f4966d..789cd2e14 100644 --- a/ouroboros/extension_loader.py +++ b/ouroboros/extension_loader.py @@ -813,7 +813,6 @@ class PluginAPIImpl: "span": span, "grid_span": span, **_widget_geometry_from_render(validated_render), - "ui_host_pending": True, }, "ui_tabs", "ui tab") def register_settings_section( @@ -2191,7 +2190,6 @@ def snapshot() -> Dict[str, Any]: dict(copy.deepcopy(value), key=key) for key, value in sorted(_ui_tabs.items()) ], - "ui_tabs_pending": [], # Settings sections follow the same host-surfaced shape as UI tabs. "settings_sections": [ dict(copy.deepcopy(value), key=key) @@ -2200,6 +2198,21 @@ def snapshot() -> Dict[str, Any]: } +def live_bundle_facts(skill_name: str) -> Optional[tuple[str, str]]: + """``(content_hash, skill_dir)`` of one LIVE extension bundle, or ``None``. + + Loader-side truth for read paths that must not re-discover skills: the + Widgets projection reports the hash as each card's ``revision`` and the + module endpoint resolves the reviewed payload directory from it. The + directory never reaches the browser. + """ + with _lock: + bundle = _extensions.get(skill_name) + if bundle is None or not bundle.skill_dir: + return None + return str(bundle.content_hash or ""), str(bundle.skill_dir) + + def get_tool(name: str) -> Optional[Dict[str, Any]]: """Return the registered extension tool, if any.""" with _lock: diff --git a/ouroboros/gateway/contracts.py b/ouroboros/gateway/contracts.py index ac646407a..9883e4092 100644 --- a/ouroboros/gateway/contracts.py +++ b/ouroboros/gateway/contracts.py @@ -8,6 +8,8 @@ from __future__ import annotations from typing import Any, Dict, List, Optional +from ouroboros.gateway.widgets import ExtensionLiveSnapshot + try: # Python 3.11+ from typing import Literal, NotRequired, Required, TypedDict # type: ignore[attr-defined] except ImportError: # pragma: no cover - CI supports Python 3.10. @@ -912,6 +914,7 @@ class UploadResponse(TypedDict): class ExtensionsIndexResponse(TypedDict, total=False): extensions: list[Dict[str, Any]] skills: list[Dict[str, Any]] + live: ExtensionLiveSnapshot lifecycle: Dict[str, Any] error: str diff --git a/ouroboros/gateway/endpoint_index.py b/ouroboros/gateway/endpoint_index.py index 645d2129d..5f072aa68 100644 --- a/ouroboros/gateway/endpoint_index.py +++ b/ouroboros/gateway/endpoint_index.py @@ -79,6 +79,7 @@ HTTP_ENDPOINTS: tuple[str, ...] = ( "POST /api/claudexor/login/{job_id}/reconcile", "DELETE /api/claudexor/credential-profiles/{harness}/{profile_id}", "PATCH /api/claudexor/credential-profiles/{harness}/{profile_id}", + "GET /api/widgets", "GET /api/extensions", "GET /api/extensions/{skill}/manifest", "GET /api/extensions/{skill}/module/{entry}", diff --git a/ouroboros/gateway/extensions.py b/ouroboros/gateway/extensions.py index a64ef6e41..4cec1dd43 100644 --- a/ouroboros/gateway/extensions.py +++ b/ouroboros/gateway/extensions.py @@ -317,14 +317,6 @@ def _build_extensions_index(drive_root, repo_path): prefix = extension_name_prefix(skill_name) return sum(1 for name in live_snapshot.get("ws_handlers", []) if str(name).startswith(prefix)) - def _pending_ui_tabs(skill_name: str) -> list[str]: - prefix = f"{skill_name}:" - return [ - str(name) - for name in live_snapshot.get("ui_tabs_pending", []) - if str(name).startswith(prefix) - ] - # Inline ClawHub provenance so Installed UI avoids a second round-trip. try: from ouroboros.marketplace.provenance import read_provenance, read_publication_record @@ -413,7 +405,6 @@ def _build_extensions_index(drive_root, repo_path): "health_regressed": False, "last_known_good": None, "dispatch_live": False, - "ui_tabs_pending": [], "review_findings": [], "skill_review": {}, "grants": {}, @@ -436,7 +427,6 @@ def _build_extensions_index(drive_root, repo_path): or _live_route_count(s.name) or _live_ws_count(s.name) ), - "ui_tabs_pending": _pending_ui_tabs(s.name), "review_findings": list(s.review.findings or []), "skill_review": skill_review_ui_projection(drive_root, s.name), "grants": grant_status_for_skill(drive_root, s), @@ -531,9 +521,16 @@ async def api_extension_manifest(request: Request) -> JSONResponse: async def api_extension_module(request: Request) -> Response: - """Serve reviewed widget module JS only for live registered tab entries.""" - from ouroboros.config import get_skills_repo_path - from ouroboros.extension_loader import runtime_state_for_skill_name + """Serve reviewed widget module JS only for live registered tab entries. + + Authorization is the live loader state alone — the skill holds a loaded + bundle whose module tab declares exactly this entry — so this read path + never re-discovers skills or hashes payloads (DEVELOPMENT "Passive GET"). + The requesting ``srcdoc`` frame has an opaque origin, so its fetch is + cross-origin and anonymous; the reply therefore carries + ``Access-Control-Allow-Origin: *`` (no credentials are involved). + """ + from ouroboros.extension_loader import live_bundle_facts skill_name = str(request.path_params.get("skill") or "").strip() entry = str(request.path_params.get("entry") or "").strip() @@ -541,33 +538,22 @@ async def api_extension_module(request: Request) -> Response: return json_error("missing skill/module entry", 400) if "/" in entry or "\\" in entry or ".." in entry or entry.startswith("."): return json_error("invalid module entry", 400) - - drive_root = _request_drive_root(request) - repo_path = get_skills_repo_path() - state = await asyncio.to_thread( - runtime_state_for_skill_name, - skill_name, - drive_root, - repo_path=repo_path, - ) - if not state.get("desired_live"): - return json_error(f"extension {skill_name!r} not live: {state.get('reason')}", 409, state=state) - loaded = await asyncio.to_thread(find_skill, drive_root, skill_name, repo_path=repo_path) - if loaded is None: - return json_error("skill not found", 404) + facts = live_bundle_facts(skill_name) + if facts is None: + return json_error(f"extension {skill_name!r} not live", 409) # Authorize against live PluginAPI tab registrations, not only manifest ui_tab. - live = snapshot() module_declared = any( str(tab.get("skill") or "") == skill_name and str((tab.get("render") or {}).get("kind") or "") == "module" and str((tab.get("render") or {}).get("entry") or "") == entry - for tab in live.get("ui_tabs", []) + for tab in snapshot().get("ui_tabs", []) ) if not module_declared: return json_error("module entry is not declared by a live widget tab", 404) - target = (loaded.skill_dir / entry).resolve() + skill_root = pathlib.Path(facts[1]) + target = (skill_root / entry).resolve() try: - target.relative_to(loaded.skill_dir.resolve()) + target.relative_to(skill_root.resolve()) except ValueError: return json_error("module entry escapes skill directory", 400) if not target.is_file(): @@ -579,7 +565,7 @@ async def api_extension_module(request: Request) -> Response: return Response( text, media_type="application/javascript; charset=utf-8", - headers={"Cache-Control": "no-store"}, + headers={"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"}, ) diff --git a/ouroboros/gateway/router.py b/ouroboros/gateway/router.py index e6c52dc1f..a20d5d553 100644 --- a/ouroboros/gateway/router.py +++ b/ouroboros/gateway/router.py @@ -40,6 +40,7 @@ def collect_routes( api_skill_review_history_detail, api_skill_toggle, ) + from ouroboros.gateway.widgets import api_widgets from ouroboros.gateway.files import ( api_chat_upload, api_chat_upload_delete, @@ -151,6 +152,7 @@ def collect_routes( routes: list[BaseRoute] = [ Route("/api/health", endpoint=api_health), Route("/api/state", endpoint=api_state), + Route("/api/widgets", endpoint=api_widgets, methods=["GET"]), Route("/api/extensions", endpoint=api_extensions_index, methods=["GET"]), Route("/api/extensions/{skill}/manifest", endpoint=api_extension_manifest, methods=["GET"]), Route("/api/extensions/{skill}/module/{entry}", endpoint=api_extension_module, methods=["GET"]), diff --git a/ouroboros/gateway/widgets.py b/ouroboros/gateway/widgets.py new file mode 100644 index 000000000..d14c3e623 --- /dev/null +++ b/ouroboros/gateway/widgets.py @@ -0,0 +1,85 @@ +"""GET /api/widgets — the Widgets page card list, projected from the live loader. + +Built purely from the in-memory extension snapshot: no skill discovery, no +stale-review reconcile, no schedule sync, no disk hashing, no writes +(DEVELOPMENT.md "Passive GET"). ``revision`` is the owning skill's live loader +``content_hash`` — a revision FACT for the page's change signature, not an +ETag and not a cache-busting token. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, TypedDict + +from starlette.requests import Request +from starlette.responses import JSONResponse + + +class WidgetTab(TypedDict): + """One Widgets card as served by ``GET /api/widgets``.""" + + key: str + skill: str + tab_id: str + title: str + icon: str + ws_prefix: str + render: Dict[str, Any] + span: int + grid_span: int + revision: str + + +class WidgetsResponse(TypedDict): + ui_tabs: List[WidgetTab] + + +class ExtensionLiveSnapshot(TypedDict): + """``extension_loader.snapshot()`` — the ``live`` block of ``GET /api/extensions``. + + Homed beside the Widgets projection that consumes it so ``gateway/contracts.py`` + stays within its module size band. + """ + + extensions: List[str] + tools: List[str] + routes: List[str] + ws_handlers: List[str] + ui_tabs: List[Dict[str, Any]] + settings_sections: List[Dict[str, Any]] + + +def widget_tabs() -> List[WidgetTab]: + """Project live UI tabs into Widgets cards stamped with the owning skill's revision.""" + from ouroboros.extension_loader import live_bundle_facts, snapshot + + tabs: List[WidgetTab] = [] + revisions: Dict[str, str] = {} + for tab in snapshot().get("ui_tabs", []): + skill = str(tab.get("skill") or "") + if skill not in revisions: + facts = live_bundle_facts(skill) + revisions[skill] = facts[0] if facts else "" + # The TypedDict IS the projection: every declared key except the stamped + # revision comes straight from the snapshot tab (frame geometry stays in + # ``render``, which is where the page reads it). + card: Dict[str, Any] = { + name: tab.get(name) for name in WidgetTab.__annotations__ if name != "revision" + } + card["revision"] = revisions[skill] + tabs.append(card) # type: ignore[arg-type] + return tabs + + +async def api_widgets(_request: Request) -> JSONResponse: + """GET /api/widgets — live widget cards from the loader snapshot only.""" + return JSONResponse({"ui_tabs": widget_tabs()}) + + +__all__ = [ + "ExtensionLiveSnapshot", + "WidgetTab", + "WidgetsResponse", + "api_widgets", + "widget_tabs", +] diff --git a/tests/test_extension_surfaces.py b/tests/test_extension_surfaces.py index 45dcb0493..7ecb03178 100644 --- a/tests/test_extension_surfaces.py +++ b/tests/test_extension_surfaces.py @@ -252,7 +252,6 @@ def test_register_ui_tab_surfaces_hostable_widget(tmp_path): err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) assert err is None, err snap = extension_loader.snapshot() - assert snap["ui_tabs_pending"] == [] assert snap["ui_tabs"][0]["key"] == "uiwait:weather" assert snap["ui_tabs"][0]["ws_prefix"] == extension_loader.extension_name_prefix("uiwait") assert snap["ui_tabs"][0]["render"]["kind"] == "declarative" diff --git a/tests/test_extensions_api.py b/tests/test_extensions_api.py index 590e05471..676c8b43a 100644 --- a/tests/test_extensions_api.py +++ b/tests/test_extensions_api.py @@ -663,7 +663,7 @@ def test_api_extension_manifest_prefers_runtime_load_error(tmp_path, monkeypatch _stop_patches(patches) -def test_api_extensions_index_marks_widget_only_extensions_as_ui_pending( +def test_api_extensions_index_lists_widget_only_extension_tabs( tmp_path, monkeypatch ): from ouroboros import extension_loader @@ -706,10 +706,8 @@ def test_api_extensions_index_marks_widget_only_extensions_as_ui_pending( entry = next(s for s in data["skills"] if s["name"] == "ext_widget") assert entry["live_loaded"] is True assert entry["dispatch_live"] is False - assert entry["ui_tabs_pending"] == [] assert data["live"]["ui_tabs"][0]["key"] == "ext_widget:weather" assert data["live"]["ui_tabs"][0]["render"]["kind"] == "declarative" - assert data["live"]["ui_tabs_pending"] == [] finally: _stop_patches(patches) @@ -1157,6 +1155,7 @@ def test_api_extension_module_serves_only_live_declared_entry(tmp_path, monkeypa assert ok.status_code == 200, ok.text assert "window.__ok" in ok.text assert ok.headers["cache-control"] == "no-store" + assert ok.headers["access-control-allow-origin"] == "*" assert client.get("/api/extensions/ext_module/module/other.js").status_code == 404 assert client.get("/api/extensions/ext_module/module/../widget.js").status_code in {400, 404} diff --git a/tests/test_gateway_parity.py b/tests/test_gateway_parity.py index a11a76e2e..fd8afac0b 100644 --- a/tests/test_gateway_parity.py +++ b/tests/test_gateway_parity.py @@ -60,6 +60,7 @@ from ouroboros.gateway.contracts import ( VideoOutbound, ) from ouroboros.gateway.router import collect_routes +from ouroboros.gateway.widgets import WidgetTab, WidgetsResponse def _js_typedef_fields(text: str, name: str) -> set[str]: @@ -227,6 +228,8 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path): "ClaudexorLoginJobProblem", "ClaudexorCredentialProfileDeleteResponse", "ClaudexorVendorCredentialDisposition", + "WidgetTab", + "WidgetsResponse", ): assert re.search(rf"@typedef \{{Object\}} {name}\b", text), f"api_types.js missing {name}" api_client = (pathlib.Path(__file__).resolve().parent.parent / "web" / "modules" / "api_client.js").read_text( @@ -257,7 +260,8 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path): ClaudexorLoginJobResponse, ClaudexorLoginJobProblem, ClaudexorCredentialProfileDeleteResponse, ClaudexorVendorCredentialDisposition, - ClaudexorStatusReads, ClaudexorStatusResponse): + ClaudexorStatusReads, ClaudexorStatusResponse, + WidgetTab, WidgetsResponse): expected = set(get_type_hints(cls, include_extras=True)) actual = _js_typedef_fields(text, cls.__name__) assert actual == expected, f"{cls.__name__} JSDoc fields drifted: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}" diff --git a/tests/test_gateway_smoke.py b/tests/test_gateway_smoke.py index 0f7876659..8e3135eb1 100644 --- a/tests/test_gateway_smoke.py +++ b/tests/test_gateway_smoke.py @@ -16,9 +16,14 @@ def test_gateway_core_routes_and_health_shape(tmp_path): assert "/api/ui/preferences" in paths assert "/ws" in paths assert "/api/extensions" in paths + assert "/api/widgets" in paths app = Starlette(routes=routes) with TestClient(app) as client: response = client.get("/api/health") + widgets = client.get("/api/widgets") + assert widgets.status_code == 200 + assert set(widgets.json()) == {"ui_tabs"} + assert isinstance(widgets.json()["ui_tabs"], list) assert response.status_code == 200 payload = response.json() assert payload["status"] == "ok" diff --git a/tests/test_gateway_widgets.py b/tests/test_gateway_widgets.py new file mode 100644 index 000000000..c5fd36ded --- /dev/null +++ b/tests/test_gateway_widgets.py @@ -0,0 +1,165 @@ +"""``GET /api/widgets`` and the module endpoint read the live loader only. + +Both are hot Widgets-page paths (DEVELOPMENT.md "Passive GET"): they must not +re-discover skills, reconcile review jobs, sync schedules, or hash payloads. +""" +from __future__ import annotations + +import importlib + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from ouroboros import extension_loader +from ouroboros.gateway.router import collect_routes +from ouroboros.gateway.widgets import WidgetTab +from tests._shared import clean_extension_runtime_state +from tests.test_extension_loader import _prepare_extension + + +@pytest.fixture(autouse=True) +def _clean_loader(monkeypatch): + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + clean_extension_runtime_state() + yield + clean_extension_runtime_state() + + +# Every seam a discovery/reconcile/sync/hash could enter the read path through. +_PASSIVE_SEAMS = ( + ("ouroboros.skill_loader", "discover_skills"), + ("ouroboros.skill_loader", "find_skill"), + ("ouroboros.skill_loader", "compute_content_hash"), + ("ouroboros.extension_loader", "discover_skills"), + ("ouroboros.extension_loader", "find_skill"), + ("ouroboros.extension_loader", "compute_content_hash"), + ("ouroboros.gateway.extensions", "discover_skills"), + ("ouroboros.gateway.extensions", "find_skill"), + ("ouroboros.skill_review_runner", "reconcile_stale_review_jobs"), + ("supervisor.queue", "sync_skill_schedules"), +) + + +def _arm_counters(monkeypatch) -> dict[str, int]: + """Wrap every seam in a counting delegate (the real call still runs). + + Arm AFTER the app is built: a module first-imported while a sibling seam + is wrapped captures the wrapper as its own original, and monkeypatch then + faithfully "restores" that capture. Delegating wrappers keep even such a + capture behaviour-preserving; building the app first avoids it entirely. + """ + modules = {name: importlib.import_module(name) for name, _attr in _PASSIVE_SEAMS} + calls: dict[str, int] = {} + for module_name, attr in _PASSIVE_SEAMS: + label = f"{module_name}.{attr}" + calls[label] = 0 + original = getattr(modules[module_name], attr) + + def _counted(*args, _label=label, _original=original, **kwargs): + calls[_label] += 1 + return _original(*args, **kwargs) + + monkeypatch.setattr(modules[module_name], attr, _counted) + return calls + + +def _client(tmp_path) -> TestClient: + return TestClient(Starlette(routes=collect_routes(data_dir=tmp_path))) + + +def test_api_widgets_projects_live_tabs_without_discovery(tmp_path, monkeypatch): + loaded, _, drive_root = _prepare_extension( + tmp_path, + "ext_widget", + "def register(api):\n" + " api.register_ui_tab('weather', 'Weather', icon='cloud', render={'kind': 'declarative', " + "'schema_version': 1, 'components': [{'type': 'markdown', 'text': 'ok'}]})\n", + permissions=["widget"], + ) + err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) + assert err is None, err + live_tab = extension_loader.snapshot()["ui_tabs"][0] + client = _client(tmp_path) # imports every gateway module before the seams are wrapped + calls = _arm_counters(monkeypatch) + + with client: + response = client.get("/api/widgets") + assert response.status_code == 200, response.text + payload = response.json() + assert set(payload) == {"ui_tabs"} + assert len(payload["ui_tabs"]) == 1 + tab = payload["ui_tabs"][0] + # Exact contract shape: the TypedDict keys, nothing else (the dead + # two-phase flags are gone; framed geometry is covered below). + assert set(tab) == set(WidgetTab.__annotations__) + assert tab == { + "key": "ext_widget:weather", + "skill": "ext_widget", + "tab_id": "weather", + "title": "Weather", + "icon": "cloud", + "ws_prefix": extension_loader.extension_name_prefix("ext_widget"), + "render": live_tab["render"], + "span": 1, + "grid_span": 1, + "revision": loaded.content_hash, + } + assert tab["revision"] and tab["revision"] == extension_loader.live_bundle_facts("ext_widget")[0] + + extension_loader.unload_extension("ext_widget") + assert client.get("/api/widgets").json() == {"ui_tabs": []} + assert all(count == 0 for count in calls.values()), calls + + +def test_api_extension_module_serves_live_entry_without_discovery(tmp_path, monkeypatch): + skill_dir = tmp_path / "skills" / "ext_module" + skill_dir.mkdir(parents=True) + # Written BEFORE the payload hash is taken so the reviewed hash covers it. + (skill_dir / "widget.js").write_text("window.__ok = true;\n", encoding="utf-8") + loaded, _, drive_root = _prepare_extension( + tmp_path, + "ext_module", + "def register(api):\n" + " api.register_ui_tab('module', 'Module', render={'kind': 'module', 'entry': 'widget.js', 'height': 480})\n", + permissions=["widget"], + ) + err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) + assert err is None, err + client = _client(tmp_path) # imports every gateway module before the seams are wrapped + calls = _arm_counters(monkeypatch) + + with client: + # Framed geometry rides inside ``render`` (where the page reads it), never + # as a promoted top-level card key. + card = client.get("/api/widgets").json()["ui_tabs"][0] + assert card["render"]["height"] == 480 and "height" not in card + ok = client.get("/api/extensions/ext_module/module/widget.js") + assert ok.status_code == 200, ok.text + assert "window.__ok" in ok.text + assert ok.headers["content-type"].startswith("application/javascript") + assert ok.headers["cache-control"] == "no-store" + assert ok.headers["access-control-allow-origin"] == "*" + # Exact-entry authorization stays; an unloaded skill is "not live". + assert client.get("/api/extensions/ext_module/module/other.js").status_code == 404 + assert client.get("/api/extensions/ext_module/module/plugin.py").status_code == 404 + assert client.get("/api/extensions/nope/module/widget.js").status_code == 409 + extension_loader.unload_extension("ext_module") + assert client.get("/api/extensions/ext_module/module/widget.js").status_code == 409 + assert all(count == 0 for count in calls.values()), calls + + +def test_live_bundle_facts_reports_loaded_bundle_only(tmp_path): + assert extension_loader.live_bundle_facts("absent") is None + loaded, _, drive_root = _prepare_extension( + tmp_path, + "ext_facts", + "def register(api):\n pass\n", + permissions=[], + ) + assert extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) is None + content_hash, skill_dir = extension_loader.live_bundle_facts("ext_facts") + assert content_hash == loaded.content_hash + assert skill_dir == str(loaded.skill_dir.resolve()) + extension_loader.unload_extension("ext_facts") + assert extension_loader.live_bundle_facts("ext_facts") is None diff --git a/tests/test_marketplace_provenance_contract.py b/tests/test_marketplace_provenance_contract.py index 49bbaec4f..50d083155 100644 --- a/tests/test_marketplace_provenance_contract.py +++ b/tests/test_marketplace_provenance_contract.py @@ -129,7 +129,6 @@ def test_required_projection_fields_match_between_endpoints(monkeypatch): "routes": [], "ws_handlers": [], "ui_tabs": [], - "ui_tabs_pending": [], } def _stub_read_provenance(*_a, **_kw): diff --git a/tests/test_ouroboroshub_adopt.py b/tests/test_ouroboroshub_adopt.py index 3dd2e1c68..1ba6e60e2 100644 --- a/tests/test_ouroboroshub_adopt.py +++ b/tests/test_ouroboroshub_adopt.py @@ -617,7 +617,7 @@ def test_extensions_index_rows_expose_loader_content_hash(monkeypatch, tmp_path) monkeypatch.setattr( extensions_api, "snapshot", - lambda: {"tools": [], "routes": [], "ws_handlers": [], "ui_tabs": [], "ui_tabs_pending": []}, + lambda: {"tools": [], "routes": [], "ws_handlers": [], "ui_tabs": []}, ) monkeypatch.setattr(supervisor_queue, "sync_skill_schedules", lambda *_a, **_kw: None) monkeypatch.setattr( diff --git a/web/modules/api_client.js b/web/modules/api_client.js index 1ca3316a5..c426b6424 100644 --- a/web/modules/api_client.js +++ b/web/modules/api_client.js @@ -224,6 +224,13 @@ export const apiClient = { */ providerTest: (payload) => jsonPost('/api/providers/test', payload), extensions: () => fetchJson('/api/extensions', { cache: 'no-store' }), + /** + * Widgets page cards: live extension UI tabs projected from the loader + * snapshot (no skill discovery), each stamped with the owning skill's + * payload `revision`. + * @returns {Promise} + */ + widgets: () => fetchJson('/api/widgets', { cache: 'no-store' }), skillPublishPreflight, createTask, skillLifecycleQueue: () => fetchJson('/api/skills/lifecycle-queue', { cache: 'no-store' }), diff --git a/web/modules/api_types.js b/web/modules/api_types.js index d913db356..0b3b142b9 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -722,6 +722,29 @@ * @property {boolean=} identity_collision */ +/** + * One Widgets card from `GET /api/widgets` (`gateway/widgets.py::WidgetTab`). + * `revision` is the owning skill's live payload content hash — a change + * signature for the page, not an ETag or cache token. Frame geometry stays + * inside `render`. + * @typedef {Object} WidgetTab + * @property {string} key + * @property {string} skill + * @property {string} tab_id + * @property {string} title + * @property {string} icon + * @property {string} ws_prefix + * @property {Object} render + * @property {number} span + * @property {number} grid_span + * @property {string} revision + */ + +/** + * @typedef {Object} WidgetsResponse + * @property {WidgetTab[]} ui_tabs + */ + /** * One `/api/marketplace/ouroboroshub/catalog` result row (additive hubflow fields). * `POST /api/marketplace/ouroboroshub/install` additionally accepts the adopt