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>
This commit is contained in:
Ouroboros 2026-09-02 17:22:55 +03:00
parent a76961de6a
commit b2bcd659c1
16 changed files with 337 additions and 44 deletions

View file

@ -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/<skill>/manifest, ALL /api/extensions/<skill>/<rest:path>, POST /api/skills/<skill>/toggle, POST /api/skills/<skill>/delete, POST /api/skills/<skill>/review, POST /api/skills/<skill>/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/<skill>/...` 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/<skill>/...` 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` |

View file

@ -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:

View file

@ -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

View file

@ -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}",

View file

@ -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": "*"},
)

View file

@ -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"]),

View file

@ -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",
]

View file

@ -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"

View file

@ -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}

View file

@ -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)}"

View file

@ -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"

View file

@ -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

View file

@ -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):

View file

@ -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(

View file

@ -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<import('./api_types.js').WidgetsResponse>}
*/
widgets: () => fetchJson('/api/widgets', { cache: 'no-store' }),
skillPublishPreflight,
createTask,
skillLifecycleQueue: () => fetchJson('/api/skills/lifecycle-queue', { cache: 'no-store' }),

View file

@ -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