Files
hermes-agent/tests/hermes_platform/test_declaration.py
Siddharth Balyan 3aaae8c537 Plugins: apps are found wherever they are installed on Windows, macOS and Linux (#134050)
* feat(platform): app declarations list every place an app may be installed

An app block's location may now be one path or an ordered list. A list
item is a path, where a `*` segment stands for a versioned folder and the
highest version is tried first, or a mapping naming a location kind:
command (PATH), uninstall_registry (InstallLocation plus a file, Windows),
app_bundle (/Applications, ~/Applications), flatpak (app id) and snap.
The directories behind each kind live in known_dirs.

Each kind's OS, key, presence and locator are one LOCATION_KINDS entry in
resolver/app.py. The declaration parser validates against that table, so
a new kind is one entry instead of three coordinated edits in two modules.

With requires.min_version, availability reads the version of every
present copy and returns the first that qualifies, so an old copy found
first does not hide a newer one; if none qualifies it reports the highest
readable version. A Linux block may omit a version source (Linux has
none) and is then gated on presence only; Windows and macOS blocks still
need one, and a Linux-only declaration cannot set a minimum.

Brought over from #129246. The Blender 0.2.0 catalog pin stays out: older
clients reject a list location, so it follows a release that ships this.

* fix(platform): bad app declarations and vanished registry entries no longer break app lookup (review)

A location with a non-string kind raises DeclarationError instead of TypeError.
A location's file and key value are checked after stripping, so a leading / or \ or a drive cannot escape the install folder.
An uninstall registry entry that cannot be opened or read is skipped instead of ending the scan of its root.
2026-10-07 00:58:22 +05:30

254 lines
12 KiB
Python

"""Declaration parser, availability, and skill-gate contracts.
Fixtures are dicts passed to ``parse_declaration`` (never YAML files); fixture paths satisfy the
OS rule they test under (`C:/x/y.exe` for win32, `/x` elsewhere).
"""
from __future__ import annotations
import plistlib
import sys
from pathlib import Path
import pytest
from hermes_platform.declaration import DeclarationError, parse_declaration
from hermes_platform.resolver.app import AppDef, AppLocation, AppResolver
from hermes_platform.resolver.availability import Availability, availability, version_at_least
WHERE = "test-plugin/plugin.yaml"
def _bundle(tmp_path: Path, version: str) -> Path:
app = tmp_path / "Applications" / "Thing.app"
(app / "Contents").mkdir(parents=True, exist_ok=True)
with open(app / "Contents" / "Info.plist", "wb") as fh:
plistlib.dump({"CFBundleShortVersionString": version}, fh)
return app
def test_app_block_parses_into_one_appdef_per_os():
decl = parse_declaration("thing-mcp", {
"darwin": {"presence": "bundle", "location": "/Applications/Thing.app", "version": {"kind": "plist"}},
"win32": {
"presence": "executable",
"location": "%ProgramFiles%/Thing/thing.exe",
"version": {"kind": "uninstall_registry", "display_name_prefix": "Thing"},
"liveness": {"kind": "server_json", "path": "%LOCALAPPDATA%/Thing/server.json", "endpoint_path": "/rpc"},
},
}, {"app": True, "min_version": "2.3.0"}, where=WHERE)
assert decl.app is not None and set(decl.app.per_os) == {"darwin", "win32"}
mac, win = decl.app_for("darwin"), decl.app_for("win32")
assert mac is not None and win is not None
assert mac.presence == "bundle" and mac.version_kind == "plist" and mac.liveness_kind == "none"
assert win.version_arg == "Thing" and win.endpoint_path == "/rpc" and win.liveness_pid_key == "pid"
assert decl.requires_app is True and decl.min_version == "2.3.0"
@pytest.mark.parametrize("raw_app, raw_requires, message", [
(None, {"app": True}, "no 'app' block"),
(None, {"min_version": "1.0\n"}, "dotted numeric"),
({"linux": {"presence": "executable", "location": "/x/y", "version": {"kind": "plist"}}}, None,
"only valid under app.darwin"),
({"win32": {"presence": "executable", "location": "C:/x/y.exe", "version": {"kind": "uninstall_registry"}}}, None,
"display_name_prefix"),
({"freebsd": {"presence": "executable", "location": "/x"}}, None, "unknown OS keys"),
({"win32": {"presence": "executable", "location": "C:/x/y.exe"}}, {"min_version": "1.0"},
"needs requires.app"),
({"win32": {"presence": "executable", "location": "C:/x/y.exe"}}, {"app": True, "min_version": "latest"},
"dotted numeric"),
({"win32": {"presence": "executable", "location": "C:/x/y.exe"}}, {"app": True, "min_version": "1.0"},
"needs a version source"),
({"darwin": {"presence": "bundle", "location": "../Up/Thing.app"}}, None, "must be absolute"),
({"darwin": {"presence": "bundle", "location": "Relative/Thing.app"}}, None, "must be absolute"),
({"darwin": {"presence": "bundle", "location": "https://example.test/Thing.app"}}, None, "must be absolute"),
({"win32": {"presence": "executable", "location": "Thing/thing.exe"}}, None, "must be absolute"),
(None, {"gpu": "amd"}, "requires.gpu must be one of"),
({"darwin": {"presence": "bundle", "location": "/Applications/Thing */**/Thing.app"}}, None, "must be absolute"),
({"linux": {"presence": "executable", "location": []}}, None, "location is required"),
({"linux": {"presence": "executable", "location": [{"kind": "registry", "name": "thing"}]}}, None,
"must be a path or a mapping with kind one of"),
({"linux": {"presence": "executable", "location": [{"kind": "app_bundle", "name": "Thing.app"}]}}, None,
"only valid under app.darwin"),
({"darwin": {"presence": "bundle", "location": [{"kind": "command", "name": "thing"}]}}, None,
"needs app.darwin.presence executable"),
({"linux": {"presence": "executable", "location": [{"kind": "snap", "name": "../thing"}]}}, None,
"location\\[0\\].name must be a bare name"),
({"win32": {"presence": "executable", "location": [{"kind": "uninstall_registry", "display_name_prefix": "Thing"}]}},
None, "location\\[0\\].file must be a relative path"),
({"linux": {"presence": "executable", "location": "/usr/bin/thing"}}, {"app": True, "min_version": "1.0"},
"needs a version source under app.linux"),
])
def test_invalid_blocks_name_the_rule(raw_app, raw_requires, message):
with pytest.raises(DeclarationError, match=message):
parse_declaration("thing-mcp", raw_app, raw_requires, where=WHERE)
def test_availability_no_requirements_does_no_io():
decl = parse_declaration("thing-mcp", None, None, where=WHERE)
result = availability(decl, os_family="freebsd")
assert result.state == "no_requirements" and result.offerable
@pytest.mark.parametrize("gpu_class, state", [
("nvidia", "no_requirements"),
("unknown", "no_requirements"),
("intel", "unsupported_gpu"),
("apple_silicon", "unsupported_gpu"),
("none", "unsupported_gpu"),
])
def test_requires_gpu_gates_on_the_host_gpu_class(monkeypatch, gpu_class, state):
"""No `app:` block is needed; an unreadable GPU passes; the OS gate runs first and reads no GPU."""
from hermes_platform.host import facts
monkeypatch.setattr(facts, "gpu_class", lambda: gpu_class)
decl = parse_declaration("thing-mcp", None, {"gpu": "nvidia"}, where=WHERE)
result = availability(decl, os_family="win32")
assert result.state == state and result.offerable == (state == "no_requirements")
monkeypatch.setattr(facts, "gpu_class", lambda: pytest.fail("the OS gate must not read the GPU"))
both = parse_declaration("thing-mcp", {"win32": {"presence": "executable", "location": "C:/x/y.exe"}},
{"app": True, "gpu": "nvidia"}, where=WHERE)
assert availability(both, os_family="darwin").state == "unsupported_os"
def test_availability_unsupported_os_when_no_block_for_host():
decl = parse_declaration(
"thing-mcp",
{"win32": {"presence": "executable", "location": "C:/x/y.exe"}},
{"app": True},
where=WHERE,
)
assert availability(decl, os_family="darwin").state == "unsupported_os"
@pytest.mark.platforms("macos")
def test_availability_missing_then_present_then_version_gate(tmp_path):
location = tmp_path / "Applications" / "Thing.app"
decl = parse_declaration(
"thing-mcp",
{"darwin": {"presence": "bundle", "location": str(location), "version": {"kind": "plist"}}},
{"app": True, "min_version": "2.3.0"},
where=WHERE,
)
missing = availability(decl, os_family="darwin")
assert missing.state == "missing_app" and str(tmp_path) in (missing.path or "")
_bundle(tmp_path, "2.2.9")
old = availability(decl, os_family="darwin")
assert old.state == "version_too_old" and old.version == "2.2.9" and old.min_version == "2.3.0"
_bundle(tmp_path, "2.3.0")
ok = availability(decl, os_family="darwin")
assert ok.state == "available" and ok.version == "2.3.0" and ok.offerable
(location / "Contents" / "Info.plist").write_bytes(b"invalid plist")
assert availability(decl, os_family="darwin").state == "version_too_old"
def test_location_list_parses_each_kind_in_order():
decl = parse_declaration("thing-mcp", {
"win32": {"presence": "executable", "location": [
{"kind": "uninstall_registry", "display_name_prefix": "Thing", "file": "bin/thing.exe"},
"%ProgramFiles%/Thing */thing.exe",
]},
"linux": {"presence": "executable", "location": [
{"kind": "command", "name": "thing"}, {"kind": "flatpak", "app_id": "org.thing.Thing"},
]},
}, None, where=WHERE)
win, linux = decl.app_for("win32"), decl.app_for("linux")
assert win is not None and linux is not None
assert win.locations == (AppLocation("uninstall_registry", "Thing", "bin/thing.exe"),
AppLocation("path", "%ProgramFiles%/Thing */thing.exe"))
assert linux.locations == (AppLocation("command", "thing"), AppLocation("flatpak", "org.thing.Thing"))
def test_min_version_lets_a_linux_block_go_without_a_version_source():
decl = parse_declaration("thing-mcp", {
"darwin": {"presence": "bundle", "location": "/Applications/Thing.app", "version": {"kind": "plist"}},
"linux": {"presence": "executable", "location": [{"kind": "command", "name": "thing"}]},
}, {"app": True, "min_version": "2.0"}, where=WHERE)
linux = decl.app_for("linux")
assert decl.min_version == "2.0" and linux is not None and linux.version_kind == "none"
@pytest.mark.platforms("macos")
def test_min_version_picks_a_newer_copy_found_after_an_old_one(tmp_path, monkeypatch):
monkeypatch.setenv("THINGROOT", str(tmp_path))
_bundle(tmp_path / "old", "1.0")
_bundle(tmp_path / "new", "2.4")
decl = parse_declaration("thing-mcp", {"darwin": {
"presence": "bundle",
"location": ["$THINGROOT/old/Applications/Thing.app", "$THINGROOT/new/Applications/Thing.app"],
"version": {"kind": "plist"},
}}, {"app": True, "min_version": "2.0"}, where=WHERE)
ok = availability(decl, os_family="darwin")
assert ok.state == "available" and ok.version == "2.4" and str(tmp_path / "new") in (ok.path or "")
too_old = parse_declaration("thing-mcp", {"darwin": {
"presence": "bundle", "location": "$THINGROOT/old/Applications/Thing.app", "version": {"kind": "plist"},
}}, {"app": True, "min_version": "2.0"}, where=WHERE)
assert availability(too_old, os_family="darwin").version == "1.0"
def test_availability_is_not_a_boolean():
with pytest.raises(TypeError):
bool(Availability("available"))
@pytest.mark.parametrize("found, minimum, expected", [
("2.3.0", "2.3.0", True),
("2.3.0-beta", "2.3.0", False),
("11.0.9.509", "11.0.9", True),
("11.0.9", "11.0.9.509", False),
("2.\u0663.0", "2.3.0", False),
("1234567890", "2.3.0", False),
])
def test_version_at_least_accepts_only_bounded_ascii_components(found, minimum, expected):
assert version_at_least(found, minimum) is expected
def test_unexpanded_locations_are_missing_even_when_cwd_contains_them(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
for location in ("%HERMES_TEST_UNSET_VAR%/x.exe", "$HERMES_TEST_UNSET_VAR/x"):
path = tmp_path / location
path.parent.mkdir(exist_ok=True)
path.write_text("fixture", encoding="utf-8")
definition = AppDef("thing", sys.platform, "executable", (AppLocation("path", location),))
assert AppResolver(definition).locate().kind == "missing"
def test_registry_mutations_are_visible_and_notify_once(monkeypatch):
from hermes_platform import declaration
decl = parse_declaration("thing-mcp", None, None, where=WHERE)
changes = []
monkeypatch.setattr(declaration, "on_change", lambda: changes.append(None))
declaration.register("thing-mcp", decl)
assert declaration.lookup("thing-mcp") is decl
declaration.unregister("thing-mcp")
assert declaration.lookup("thing-mcp") is None
declaration.register("other", decl)
declaration.clear()
assert declaration.lookup("other") is None
assert len(changes) == 4
def test_skill_requires_apps_gate(tmp_path, monkeypatch):
from agent import skill_utils
from hermes_platform import declaration
monkeypatch.setattr(declaration, "_REGISTRY", {})
decl = parse_declaration(
"thing-mcp",
{sys.platform: {"presence": "executable", "location": str(tmp_path / "thing")}},
{"app": True},
where=WHERE,
)
declaration.clear()
try:
assert skill_utils.skill_matches_apps({}) is True
assert skill_utils.skill_matches_apps({"requires_apps": ["unknown"]}) is False
declaration.register("thing-mcp", decl)
assert skill_utils.skill_matches_apps({"requires_apps": ["thing-mcp"]}) is False
(tmp_path / "thing").write_text("fixture", encoding="utf-8")
assert skill_utils.skill_matches_apps({"requires_apps": ["thing-mcp"]}) is True
finally:
declaration.clear()