mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
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.
378 lines
19 KiB
Python
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"
|