Files
hermes-agent/hermes_platform/resolver/availability.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

118 lines
4.6 KiB
Python

"""Whether a catalog entry can be offered on this host, from its `app` and `requires` blocks.
`availability` runs `locate`, `inspect` and the host's cached GPU fact only. It never probes, never spawns,
never connects, and holds no cache: the callers that need a TTL (the tool registry) already have one.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Literal, Protocol
from hermes_platform.host import facts
from hermes_platform.resolver.app import AppDef, AppResolver
from hermes_platform.resolver.core import Candidate, CheckState, Resolution
AvailabilityState = Literal[
"available",
"installed_not_running",
"missing_app",
"version_too_old",
"unsupported_os",
"unsupported_gpu",
"no_requirements",
]
OFFERABLE: frozenset[str] = frozenset({"available", "no_requirements"})
class _HasRequirements(Protocol):
"""The slice of a catalog entry `availability` reads; keeps this module free of `hermes_cli` imports."""
@property
def requires_app(self) -> bool: ...
@property
def min_version(self) -> str | None: ...
@property
def required_gpu(self) -> str | None: ...
def app_for(self, os_family: str) -> AppDef | None: ...
@dataclass(frozen=True)
class Availability:
state: AvailabilityState
version: str | None = None
path: str | None = None
min_version: str | None = None
@property
def offerable(self) -> bool:
return self.state in OFFERABLE
def __bool__(self) -> bool:
raise TypeError("Availability is not a boolean; read .offerable or .state")
def as_dict(self) -> dict:
return {"state": self.state, "version": self.version, "path": self.path, "min_version": self.min_version}
def _version_parts(version: str) -> list[int] | None:
if not re.fullmatch(r"[0-9]{1,9}(?:\.[0-9]{1,9})*", version):
return None
return [int(component) for component in version.split(".")]
def version_at_least(found: str, minimum: str) -> bool:
"""Compare dot-separated decimal components, failing closed on invalid input."""
a, b = _version_parts(found), _version_parts(minimum)
if a is None or b is None:
return False
width = max(len(a), len(b))
a += [0] * (width - len(a))
b += [0] * (width - len(b))
return a >= b
def availability(entry: _HasRequirements, *, os_family: str | None = None) -> Availability:
gpu = entry.required_gpu
if not entry.requires_app and gpu is None:
return Availability("no_requirements")
osf = os_family or facts.os_family()
definition = entry.app_for(osf) if entry.requires_app else None
if entry.requires_app and definition is None:
return Availability("unsupported_os", min_version=entry.min_version)
# An unreadable GPU (`unknown`) passes: refusing on a failed registry or sysfs read would lock out
# the machines that have the GPU, and the MCP connection check still applies.
if gpu is not None and facts.gpu_class() not in (gpu, "unknown"):
return Availability("unsupported_gpu", min_version=entry.min_version)
if definition is None:
return Availability("no_requirements")
resolver = AppResolver(definition)
res = resolver.locate()
looked_at = res.command[0] if res.command else (res.candidates[0].value if res.candidates else None)
if not res.found:
return Availability("missing_app", path=looked_at, min_version=entry.min_version)
if definition.version_kind == "none":
return Availability("available", path=looked_at, min_version=entry.min_version)
def version_of(copy: Candidate) -> str | None:
obs = resolver.inspect(Resolution(res.kind, (copy,))).version
return obs.value if obs.state is CheckState.PRESENT else None
if not entry.min_version:
return Availability("available", version=version_of(res.present[0]), path=looked_at)
# Every present copy is read, so an old copy found first does not hide a newer one found later;
# location order only breaks ties among copies that qualify.
copies = [(copy.value, version_of(copy)) for copy in res.present]
for path, version in copies:
if version is not None and version_at_least(version, entry.min_version):
return Availability("available", version=version, path=path, min_version=entry.min_version)
readable = [(parts, path, version) for path, version in copies
if version is not None and (parts := _version_parts(version)) is not None]
_, path, version = max(readable, key=lambda item: item[0]) if readable else (None, looked_at, None)
return Availability("version_too_old", version=version, path=path, min_version=entry.min_version)