ouroboros/tests/test_gateway_widgets.py
Ouroboros 269cd303ef F2 review wave: one fix batch over the upstream absorption
Review wave on the merge commit 9698e2e0 (four Claudexor lanes: codex sol
triad/scope, fable, grok; four adversarial lenses: hand-merge reconstruction,
lost upstream changes, relocation mechanics, docs and test honesty). Every
output was read to EOF and dispositioned; this is the one batch.

Runtime:
- context_fit imported the retired `ouroboros.delivery_protocol` (folded into
  loop_delivery on the v7 line): `import ouroboros.loop` failed on a clean
  checkout. The plain-text extractor is now a call-time wrapper over
  loop_messages (loop_messages imports ouroboros.llm at module top, so a
  top-level import would be a cycle). The venv's editable install of the live
  repo had masked this; the battery now strips that finder.
- masked-green exit disclosure gates on the process fact exit_code == 0, not
  the typed status, so the undeclared-output and artifact-error publications
  keep upstream's disclosure; one test per site.
- loop_nudges reaches the relocated skill-trace lookup through the loop handle
  (facade patches bite); the declared handle set follows.
- delegate_custody_usage: unreachable trailing return removed.
- ChatOutbound / api_types declare `cancel_physical_task_id` (the incident meta
  emits it).

Tests: patch targets follow the v7 leaves (wire-recovery preparer in
llm_attempt, every lane that binds the physical-candidate executor, the
process-role owner for reconcile receipts, the four multi-line reviewer-slot
patches that stayed on the dead name); the Windows skip marker returns to the
symlink widget test; adapted upstream tests pin exact messages and exported
values instead of loosened predicates; vacuous scans (chat-id records,
progress_meta keys, benchmark slot counts) are made to bite; residual text-
inspection inventory follows the core_secret_paths split; a keyless system-E2E
scenario (S26) proves the owner stop of an in-flight direct chat turn.

Docs: ARCHITECTURE component-map pointers follow the merged owners
(outcomes.extract_final_answer, review_verdict.aggregate_dialogue_status,
FORBIDDEN_SKILL_SETTINGS, worker_chat_lane namer, tools/tool_context.py,
process_custody.py row, nine hot stores, cost-breakdown leaf); cost_projection
row states the 7.0 contract (retired cost_usd spellings are read-only
tolerance, never emitted); contracts/api_v1.py and delivery_protocol are no
longer named as live modules; relocation-ledger corrections appended.
2026-09-04 20:17:45 +00:00

378 lines
19 KiB
Python

"""``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 json
import pathlib
import subprocess
import sys
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 response.headers["cache-control"] == "no-store"
assert tab["revision"] and tab["revision"] == extension_loader.live_widget_projection()[0]["revision"]
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_reviewed_js_files_without_discovery(tmp_path, monkeypatch):
skill_dir = tmp_path / "skills" / "ext_module"
for rel in ("lib", "node_modules/dep", ".hidden"):
(skill_dir / rel).mkdir(parents=True)
# Written BEFORE the payload hash is taken so the reviewed hash covers them.
files = {
"widget.js": "window.__ok = true;\n",
"other.js": "window.__other = true;\n",
"lib/x.js": "window.__lib = 1;\n",
"lib/y.mjs": "export const y = 2;\n",
"node_modules/dep/index.js": "module.exports = 1;\n", # review-opaque: never captured
".hidden/h.js": "window.__hidden = 1;\n", # dot directory: never captured
".hidden.js": "window.__dotfile = 1;\n", # dot-prefixed file: never captured
"notes.txt": "not javascript\n",
}
for rel, body in files.items():
(skill_dir / rel).write_text(body, 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
other, _, _ = _prepare_extension(tmp_path, "ext_other", "def register(api):\n pass\n", permissions=[])
assert extension_loader.load_extension(other, lambda: {}, drive_root=drive_root) is None
client = _client(tmp_path) # imports every gateway module before the seams are wrapped
calls = _arm_counters(monkeypatch)
def get(path: str):
return client.get(f"/api/extensions/ext_module/module/{path}")
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
# Q21=A: every reviewed .js/.mjs of the payload is served by relative path,
# not only the declared entry.
for rel in ("widget.js", "other.js", "lib/x.js", "lib/y.mjs"):
ok = get(rel)
assert ok.status_code == 200, (rel, ok.text)
assert ok.text == files[rel]
assert ok.headers["content-type"] == "application/javascript; charset=utf-8"
assert ok.headers["cache-control"] == "no-store"
assert ok.headers["access-control-allow-origin"] == "*"
# Reviewed bytes = served bytes by construction: every file was captured
# when the bundle loaded, so an edit on disk afterwards is NOT served
# until the skill reloads (which review freshness requires anyway).
(skill_dir / "lib" / "x.js").write_text("window.__edited_after_load = true;\n", encoding="utf-8")
assert get("lib/x.js").text == files["lib/x.js"]
# The sources live on the loader bundle, never in a browser-facing projection.
assert "window.__" not in json.dumps(extension_loader.snapshot())
assert "window.__" not in json.dumps(client.get("/api/widgets").json())
# A refusal carries the same no-store/ACAO headers as a 200: the opaque-origin
# frame's ``import()`` then reads the 4xx instead of an unreadable CORS failure.
def refused(response, status: int, label: str) -> None:
assert response.status_code == status, (label, response.status_code, response.text)
assert response.headers["cache-control"] == "no-store", label
assert response.headers["access-control-allow-origin"] == "*", label
# Not captured: review-opaque and dot-prefixed paths, files the payload lacks.
for rel in ("node_modules/dep/index.js", ".hidden/h.js", ".hidden.js", "missing.js", "lib/missing.mjs"):
refused(get(rel), 404, rel)
# Shape-rejected before any lookup; percent-escapes arrive decoded, so an
# encoded traversal is the same ``..`` segment as a literal one.
for rel in (
"%2e%2e/widget.js", "..%2Fwidget.js", "lib%2F..%2Fwidget.js", "%2e/widget.js", "lib%5Cx.js",
"%2Flib/x.js", "lib//x.js", "%00.js", "widget.js%00", "notes.txt", "plugin.py", "lib/", "",
):
refused(get(rel), 400, rel)
# Cross-skill and unloaded: a live skill never serves another's files.
refused(client.get("/api/extensions/ext_other/module/widget.js"), 404, "ext_other")
refused(client.get("/api/extensions/nope/module/widget.js"), 409, "nope")
extension_loader.unload_extension("ext_module")
refused(get("widget.js"), 409, "unloaded")
assert all(count == 0 for count in calls.values()), calls
def test_live_widget_projection_joins_tabs_with_owner_revision(tmp_path):
"""One accessor under one lock: tab and owner revision per row; the captured
module sources are a separate one-lock read keyed by relative path."""
assert extension_loader.live_widget_projection("absent") is None
assert extension_loader.live_module_sources("absent") is None
assert extension_loader.live_widget_projection() == []
skill_dir = tmp_path / "skills" / "ext_proj"
(skill_dir / "lib").mkdir(parents=True)
(skill_dir / "widget.js").write_text("export const x = 1;\n", encoding="utf-8")
(skill_dir / "lib" / "helper.mjs").write_text("export const h = 2;\n", encoding="utf-8")
loaded, _, drive_root = _prepare_extension(
tmp_path,
"ext_proj",
"def register(api):\n"
" api.register_ui_tab('module', 'Module', render={'kind': 'module', 'entry': 'widget.js'})\n"
" api.register_ui_tab('plain', 'Plain', render={'kind': 'declarative', 'schema_version': 1, "
"'components': [{'type': 'markdown', 'text': 'ok'}]})\n",
permissions=["widget"],
)
assert extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) is None
rows = extension_loader.live_widget_projection("ext_proj")
assert [row["tab"]["key"] for row in rows] == ["ext_proj:module", "ext_proj:plain"]
assert {row["revision"] for row in rows} == {loaded.content_hash}
assert all(set(row) == {"tab", "revision"} for row in rows)
assert extension_loader.live_module_sources("ext_proj") == {
"widget.js": "export const x = 1;\n",
"lib/helper.mjs": "export const h = 2;\n",
}
assert extension_loader.live_widget_projection() == rows
# A live bundle declaring no tabs is [] with no sources, not None (the module endpoint's 409).
other, _, _ = _prepare_extension(tmp_path, "ext_notabs", "def register(api):\n pass\n", permissions=[])
assert extension_loader.load_extension(other, lambda: {}, drive_root=drive_root) is None
assert extension_loader.live_widget_projection("ext_notabs") == []
assert extension_loader.live_module_sources("ext_notabs") == {}
extension_loader.unload_extension("ext_proj")
assert extension_loader.live_widget_projection("ext_proj") is None
assert extension_loader.live_module_sources("ext_proj") is None
@pytest.mark.parametrize(
"files,expected",
[
({}, "module widget entry 'widget.js' is missing from the skill directory"),
({"widget.js": b"\xff\xfe\x00bad"}, "module widget file 'widget.js' is not UTF-8"),
({"widget.js": b"ok();\n", "lib/bad.js": b"\xff\xfe\x00bad"}, "module widget file 'lib/bad.js' is not UTF-8"),
],
ids=["missing-entry", "non-utf8-entry", "non-utf8-sibling"],
)
def test_module_widget_without_readable_sources_is_not_live(tmp_path, files, expected):
"""Every .js/.mjs is read ONCE at load; without all of them the tab (and the skill) is not live."""
skill_dir = tmp_path / "skills" / "ext_broken"
skill_dir.mkdir(parents=True)
for rel, body in files.items():
(skill_dir / rel).parent.mkdir(parents=True, exist_ok=True)
(skill_dir / rel).write_bytes(body)
loaded, _, drive_root = _prepare_extension(
tmp_path,
"ext_broken",
"def register(api):\n"
" api.register_ui_tab('module', 'Module', render={'kind': 'module', 'entry': 'widget.js'})\n",
permissions=["widget"],
)
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
assert err is not None and expected in err, err
assert extension_loader.snapshot()["ui_tabs"] == []
assert extension_loader.live_widget_projection("ext_broken") is None
def _reviewed_widget_skill(tmp_path: pathlib.Path, name: str):
"""A reviewed+enabled out-of-process widget extension the staged publication
path (``_publish_out_of_process_registration``) accepts: a real
``LoadedSkill`` with a manifest, not a namespace stub."""
from ouroboros.skill_loader import SkillReviewState, find_skill, save_enabled, save_review_state
repo_root = tmp_path / "skills"
drive_root = tmp_path / "drive"
drive_root.mkdir(exist_ok=True)
skill_dir = repo_root / name
(skill_dir / "lib").mkdir(parents=True)
(skill_dir / "plugin.py").write_text("def register(api):\n pass\n", encoding="utf-8")
(skill_dir / "SKILL.md").write_text(
"---\n"
f"name: {name}\n"
"description: widget skill\n"
"version: 0.1.0\n"
"type: extension\n"
"entry: plugin.py\n"
"permissions: [widget]\n"
"---\n"
"body\n",
encoding="utf-8",
)
loaded = find_skill(drive_root, name, repo_path=str(repo_root))
assert loaded is not None
save_enabled(drive_root, loaded.name, True)
save_review_state(drive_root, loaded.name, SkillReviewState(status="pass", content_hash=loaded.content_hash))
loaded = find_skill(drive_root, loaded.name, repo_path=str(repo_root))
assert loaded is not None
return loaded, skill_dir, drive_root
def _publish_oop(skill, drive_root, *, catalog, current_hash):
extension_loader._publish_out_of_process_registration(
skill, catalog=catalog, current_hash=current_hash,
state_dir=extension_loader.skill_state_dir(drive_root, skill.name),
settings_reader=lambda: {}, granted_keys=[], dependency_site_dirs_enabled=False,
)
@pytest.mark.skipif(sys.platform == "win32", reason="symlink creation needs privileges on Windows")
def test_module_sources_never_follow_symlinks_out_of_the_skill(tmp_path):
"""The capture is the review-hash surface: a sibling symlink escaping the skill
root is not reviewed, so it is not captured (the endpoint's 404); an escaping
ENTRY fails the registration. Exercised on the host-side catalog path, which
has no import-tree staging in front of it (the in-process loader already
refuses such a tree while staging its import copy)."""
from ouroboros.contracts.plugin_api import ExtensionRegistrationError
outside = tmp_path / "outside.js"
outside.write_text("window.__outside = true;\n", encoding="utf-8")
skill, skill_dir, drive_root = _reviewed_widget_skill(tmp_path, "oop_link")
(skill_dir / "widget.js").write_text("window.__ok = true;\n", encoding="utf-8")
(skill_dir / "lib" / "leak.js").symlink_to(outside)
catalog = {"ui_tabs": [{"key": "oop_link:m", "skill": "oop_link", "tab_id": "m", "title": "M",
"render": {"kind": "module", "entry": "widget.js"}}]}
_publish_oop(skill, drive_root, catalog=catalog, current_hash="h1")
assert extension_loader.live_module_sources("oop_link") == {"widget.js": "window.__ok = true;\n"}
extension_loader.unload_extension("oop_link")
(skill_dir / "widget.js").unlink()
(skill_dir / "widget.js").symlink_to(outside)
with pytest.raises(ExtensionRegistrationError, match="entry 'widget.js' escapes the skill directory"):
_publish_oop(skill, drive_root, catalog=catalog, current_hash="h2")
assert extension_loader.live_module_sources("oop_link") is None
def test_out_of_process_catalog_captures_module_sources_at_load(tmp_path):
"""The host-side catalog path stores the same reviewed sources as register_ui_tab."""
from ouroboros.contracts.plugin_api import ExtensionRegistrationError
skill, skill_dir, drive_root = _reviewed_widget_skill(tmp_path, "oop")
(skill_dir / "widget.js").write_text("export const oop = 1;\n", encoding="utf-8")
(skill_dir / "lib" / "x.js").write_text("export const x = 1;\n", encoding="utf-8")
catalog = {"ui_tabs": [{"key": "oop:m", "skill": "oop", "tab_id": "m", "title": "M",
"render": {"kind": "module", "entry": "widget.js"}}]}
_publish_oop(skill, drive_root, catalog=catalog, current_hash="h1")
rows = extension_loader.live_widget_projection("oop")
assert [row["tab"]["key"] for row in rows] == ["oop:m"] and rows[0]["revision"] == "h1"
assert extension_loader.live_module_sources("oop") == {
"widget.js": "export const oop = 1;\n",
"lib/x.js": "export const x = 1;\n",
}
assert "export const" not in json.dumps(extension_loader.snapshot())
extension_loader.unload_extension("oop")
# A catalog declaring an entry the payload lacks is not installed at all.
(skill_dir / "widget.js").unlink()
with pytest.raises(ExtensionRegistrationError, match="'widget.js' is missing"):
_publish_oop(skill, drive_root, catalog=catalog, current_hash="h2")
assert extension_loader.live_widget_projection("oop") is None
assert extension_loader.live_module_sources("oop") is None
def test_contracts_import_stays_transport_free():
"""``gateway/contracts.py`` re-exports the Widgets TypedDicts homed in
``gateway/widgets.py``; importing the contracts must not load Starlette.
(The ``contracts.api_v1`` re-export shim is gone in the 7.0 ABI —
``tests/test_contracts.py`` pins its absence — so the transport-free
boundary under test is ``ouroboros.gateway.contracts`` itself.)"""
code = "import sys, ouroboros.gateway.contracts; sys.exit(1 if 'starlette' in sys.modules else 0)"
proc = subprocess.run(
[sys.executable, "-c", code],
cwd=pathlib.Path(__file__).resolve().parent.parent,
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr or "starlette was imported by ouroboros.gateway.contracts"