From d416e6559952c7aaad1e900b54c24f25455d87e8 Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Sun, 21 Jun 2026 22:15:45 +0100 Subject: [PATCH] chore(capabilities): pin pluginApi compat baseline, fail CI on host-compat drift --- CHANGELOG.md | 1 + schemas/capabilities.schema.json | 6 +++ src/vouch/capabilities.py | 33 +++++++++++++++ src/vouch/models.py | 9 ++++ tests/test_capabilities.py | 70 ++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88c0498..38fb0f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to vouch are documented here. Format follows New `vouch schema list` and `vouch schema sync` commands inspect declared kinds and audit existing pages against them; `propose-page` gains `--kind` and repeatable `--meta key=value`. +- `kb.capabilities` now reports a `host_compat` block mirroring the `openclaw.compat` constraints declared in `openclaw.plugin.json` (e.g. `pluginApi`), so non-OpenClaw clients can detect compat without parsing the manifest. A new test asserts the two stay in sync and fails CI on drift (#237). - `kb.synthesize` — answer-mode retrieval over the review-gated KB. Answers a query in prose from approved claims only, with an inline `[claim_id]` citation behind every sentence, an explicit `gaps` block listing query diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index b986604e..f795ed3d 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -12,6 +12,12 @@ "title": "Context Engines", "type": "array" }, + "host_compat": { + "additionalProperties": true, + "description": "Per-host compatibility ranges (#237). Mirrors the `openclaw.compat` block in openclaw.plugin.json so non-OpenClaw clients can detect compat without parsing the manifest, e.g. {\"openclaw\": {\"pluginApi\": \">=2026.4.0\"}}.", + "title": "Host Compat", + "type": "object" + }, "knowledge_capability": { "additionalProperties": true, "title": "Knowledge Capability", diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 39872ade..b84ccaf9 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -7,10 +7,21 @@ from __future__ import annotations +import json +import logging +from pathlib import Path + from . import __version__ from .models import Capabilities from .openclaw.context_engine import describe_engine +_log = logging.getLogger(__name__) + +# Path to the plugin manifest, relative to this module. capabilities.py lives +# at src/vouch/capabilities.py; openclaw.plugin.json lives at the repo root, +# three levels up (src/vouch/ -> src/ -> repo root). +_PLUGIN_MANIFEST_PATH = Path(__file__).resolve().parent.parent.parent / "openclaw.plugin.json" + # The full method surface this implementation exposes. Keep this list in # sync with the MCP server + JSONL server registrations — `test_capabilities` # asserts they match. @@ -72,6 +83,27 @@ ] +def _load_host_compat() -> dict[str, dict[str, str]]: + """Read the `openclaw.compat` block from openclaw.plugin.json (#237). + + Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients + can detect compat without parsing the manifest themselves. Returns an + empty dict (rather than raising) if the manifest is missing or + malformed — capabilities() must never fail to report basic info just + because the manifest moved or this is installed as a standalone wheel + without the manifest packaged alongside it. + """ + try: + manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + _log.debug("openclaw.plugin.json unreadable, host_compat will be empty: %s", e) + return {} + compat = manifest.get("openclaw", {}).get("compat") + if not isinstance(compat, dict): + return {} + return {"openclaw": {k: str(v) for k, v in compat.items()}} + + def capabilities() -> Capabilities: retrieval = ["fts5", "substring"] try: @@ -94,4 +126,5 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + host_compat=_load_host_compat(), ) diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa4..e4b27727 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -456,3 +456,12 @@ class Capabilities(BaseModel): default_factory=list, description="OpenClaw context engines exposed (see openclaw.plugin.json)", ) + host_compat: dict[str, Any] = Field( + default_factory=dict, + description=( + "Per-host compatibility ranges (#237). Mirrors the " + "`openclaw.compat` block in openclaw.plugin.json so non-OpenClaw " + "clients can detect compat without parsing the manifest, e.g. " + '{"openclaw": {"pluginApi": ">=2026.4.0"}}.' + ), + ) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 736b20ba..6908a3cb 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +from pathlib import Path + +import pytest + from vouch import capabilities from vouch.jsonl_server import HANDLERS @@ -15,3 +20,68 @@ def test_capabilities_matches_jsonl_handlers() -> None: f"missing handlers={declared - implemented}, " f"missing capabilities={implemented - declared}" ) + + +# --- host_compat drift detection (#237) ----------------------------------- +# +# vouch declares openclaw.compat.pluginApi in openclaw.plugin.json. The same +# value must surface in kb.capabilities so non-OpenClaw clients can detect +# compat without parsing the manifest. These tests fail CI with a clear +# message if the two declarations drift apart. + +_MANIFEST_PATH = ( + Path(__file__).resolve().parent.parent / "openclaw.plugin.json" +) + + +def _manifest_plugin_api() -> str: + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + return manifest["openclaw"]["compat"]["pluginApi"] + + +def test_capabilities_host_compat_matches_openclaw_manifest() -> None: + """kb.capabilities.host_compat.openclaw.pluginApi must equal the + pluginApi range declared in openclaw.plugin.json. A bump in one file + without the other is exactly the "host compat drift" #237 asks CI to + catch.""" + caps = capabilities.capabilities() + manifest_range = _manifest_plugin_api() + capabilities_range = caps.host_compat.get("openclaw", {}).get("pluginApi") + assert capabilities_range == manifest_range, ( + f"host compat drift: openclaw.plugin.json declares pluginApi=" + f"{manifest_range!r} but kb.capabilities.host_compat reports " + f"{capabilities_range!r}. Keep both in sync." + ) + + +def test_capabilities_host_compat_present_and_nonempty() -> None: + """host_compat must not silently degrade to {} when the manifest is + readable -- that would defeat the drift check above by making both + sides agree on "missing" rather than catching real drift.""" + caps = capabilities.capabilities() + assert "openclaw" in caps.host_compat + assert "pluginApi" in caps.host_compat["openclaw"] + assert caps.host_compat["openclaw"]["pluginApi"].strip() != "" + + +def test_load_host_compat_returns_empty_on_missing_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """_load_host_compat must degrade gracefully (empty dict, no raise) if + the manifest is absent -- e.g. installed as a standalone wheel without + openclaw.plugin.json packaged alongside it.""" + monkeypatch.setattr( + capabilities, "_PLUGIN_MANIFEST_PATH", tmp_path / "does-not-exist.json" + ) + assert capabilities._load_host_compat() == {} + + +def test_load_host_compat_returns_empty_on_malformed_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """A malformed manifest must not crash capabilities() -- it's reporting + diagnostic info, not validating the install.""" + bad = tmp_path / "openclaw.plugin.json" + bad.write_text("{not valid json", encoding="utf-8") + monkeypatch.setattr(capabilities, "_PLUGIN_MANIFEST_PATH", bad) + assert capabilities._load_host_compat() == {}