Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

_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"
# Path to the loader-facing package.json at the repo root, three levels up
# (src/vouch/ -> src/ -> repo root). The 2026.6 openclaw repackage moved the
# `openclaw.compat.pluginApi` floor out of openclaw.plugin.json (whose
# `openclaw.*` fields the current loader ignores) and into package.json, so
# that is now the single source of truth for host compat (#237).
_PACKAGE_JSON_PATH = Path(__file__).resolve().parent.parent.parent / "package.json"

# The full method surface this implementation exposes. Keep this list in
# sync with the MCP server + JSONL server registrations — `test_capabilities`
Expand Down Expand Up @@ -90,19 +92,19 @@


def _load_host_compat() -> dict[str, dict[str, str]]:
"""Read the `openclaw.compat` block from openclaw.plugin.json (#237).
"""Read the `openclaw.compat` block from package.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
empty dict (rather than raising) if package.json 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.
without package.json packaged alongside it.
"""
try:
manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest = json.loads(_PACKAGE_JSON_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)
_log.debug("package.json unreadable, host_compat will be empty: %s", e)
return {}
compat = manifest.get("openclaw", {}).get("compat")
if not isinstance(compat, dict):
Expand Down
31 changes: 16 additions & 15 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,32 @@ def test_capabilities_matches_jsonl_handlers() -> None:

# --- 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"
# vouch declares openclaw.compat.pluginApi in package.json (the loader-facing
# manifest the 2026.6 repackage moved it to). 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.

_PACKAGE_JSON_PATH = (
Path(__file__).resolve().parent.parent / "package.json"
)


def _manifest_plugin_api() -> str:
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest = json.loads(_PACKAGE_JSON_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
pluginApi range declared in package.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"host compat drift: package.json declares pluginApi="
f"{manifest_range!r} but kb.capabilities.host_compat reports "
f"{capabilities_range!r}. Keep both in sync."
)
Expand All @@ -68,10 +69,10 @@ 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."""
package.json is absent -- e.g. installed as a standalone wheel without
package.json packaged alongside it."""
monkeypatch.setattr(
capabilities, "_PLUGIN_MANIFEST_PATH", tmp_path / "does-not-exist.json"
capabilities, "_PACKAGE_JSON_PATH", tmp_path / "does-not-exist.json"
)
assert capabilities._load_host_compat() == {}

Expand All @@ -81,7 +82,7 @@ def test_load_host_compat_returns_empty_on_malformed_manifest(
) -> None:
"""A malformed manifest must not crash capabilities() -- it's reporting
diagnostic info, not validating the install."""
bad = tmp_path / "openclaw.plugin.json"
bad = tmp_path / "package.json"
bad.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(capabilities, "_PLUGIN_MANIFEST_PATH", bad)
monkeypatch.setattr(capabilities, "_PACKAGE_JSON_PATH", bad)
assert capabilities._load_host_compat() == {}
Loading