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

440 lines
18 KiB
Python

"""Desktop-application resolver over an `AppDef` (parsed from an MCP manifest's `app:` block).
`locate` stats every declared location in order (fixed paths, PATH, uninstall entries, the flatpak,
snap and Applications directories) and lists directories for a `*` path segment; the first present
one wins. `inspect` reads the version source in-process.
`probe` re-reads the vendor's runtime file on every call; the bearer token in it never
leaves this module.
"""
from __future__ import annotations
import glob
import json
import os
import plistlib
import re
import sys
import time
from dataclasses import dataclass
from typing import Any, Callable, Iterator, Literal
from urllib.parse import urlsplit
from hermes_platform.resolver import known_dirs
from hermes_platform.resolver.base import Effort, Inspection, Probe
from hermes_platform.resolver.core import (
Candidate,
CheckState,
Kind,
LookupContext,
Observation,
Resolution,
locate_command,
)
PresenceKind = Literal["executable", "bundle"]
VersionKind = Literal["pe_resource", "plist", "uninstall_registry", "none"]
LivenessKind = Literal["server_json", "none"]
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]"})
@dataclass(frozen=True)
class AppLocation:
"""One place to look. `kind` is `path` or a key of `LOCATION_KINDS`. `value` is the path, or the
value of that kind's `key`. `file` is joined onto an uninstall entry's `InstallLocation`."""
kind: str
value: str
file: str = ""
@dataclass(frozen=True)
class AppDef:
"""One application on one OS. Paths use `%VAR%` and `~`; expansion happens at lookup."""
app_id: str
os_family: str
presence: PresenceKind
locations: tuple[AppLocation, ...]
version_kind: VersionKind = "none"
version_arg: str = ""
liveness_kind: LivenessKind = "none"
liveness_path: str = ""
liveness_pid_key: str = "pid"
liveness_url_key: str = "http"
liveness_token_key: str = "token"
endpoint_path: str = "/mcp"
def _expand(path: str) -> str:
return os.path.expandvars(os.path.expanduser(path))
@dataclass(frozen=True)
class Endpoint:
url: str
token: str = ""
def __repr__(self) -> str:
return f"Endpoint(url={self.url!r}, token=<redacted>)"
@dataclass(frozen=True)
class AppResolver:
definition: AppDef
@property
def name(self) -> str:
return self.definition.app_id
# ---- locate: stat only -------------------------------------------------------------
def locate(self, ctx: LookupContext | None = None) -> Resolution:
d = self.definition
found = [hit for loc in d.locations for hit in _locator(loc.kind)(d, loc, ctx)]
kind: Kind = next((k for c, k in found if c.present), "missing")
return Resolution(kind, tuple(c for c, _ in found))
# ---- inspect: bounded file reads and in-process OS APIs -----------------------------
def inspect(self, res: Resolution, ctx: LookupContext | None = None) -> Inspection:
if not res.found:
return Inspection(Observation.not_checked(), Observation.not_checked())
return Inspection(version=self._version(res.command[0]), signer=Observation.not_checked())
def _version(self, path: str) -> Observation[str]:
kind = self.definition.version_kind
try:
if kind == "none":
return Observation.not_checked()
if kind == "plist":
return _plist_version(path)
if kind == "pe_resource":
return _pe_version(path)
if kind == "uninstall_registry":
return _uninstall_registry_version(self.definition.version_arg)
except Exception as exc: # a vendor's plist/PE/registry entry is untrusted input; never abort the caller
return Observation(CheckState.ERROR, detail=exc.__class__.__name__)
return Observation(CheckState.UNAVAILABLE, detail=f"unknown version kind {kind}")
# ---- probe: fresh, never cached ------------------------------------------------------
def endpoint(self) -> Endpoint | None:
"""Read and validate the current runtime endpoint."""
d = self.definition
if d.liveness_kind != "server_json":
return None
session = _read_server_json(_expand(d.liveness_path), d)
if session is None or _pid_alive(session.pid).value is not True:
return None
endpoint = _endpoint_observation(session.url, d.endpoint_path)
if endpoint.state is not CheckState.PRESENT or not endpoint.value:
return None
return Endpoint(endpoint.value, session.token)
def probe(self, res: Resolution, *, effort: Effort, deadline_s: float = 3.0) -> Probe:
d = self.definition
nc: Observation = Observation.not_checked()
if d.liveness_kind != "server_json":
return Probe(running=nc, answering=nc, endpoint=nc)
session = _read_server_json(_expand(d.liveness_path), d)
if session is None:
absent = Observation(CheckState.ABSENT, False, "runtime file missing or unreadable")
return Probe(running=absent, answering=nc, endpoint=Observation(CheckState.ABSENT))
running = _pid_alive(session.pid)
endpoint_obs = _endpoint_observation(session.url, d.endpoint_path)
if effort is Effort.LOCAL or running.value is not True or endpoint_obs.state is not CheckState.PRESENT:
return Probe(running=running, answering=nc, endpoint=endpoint_obs)
answering = _mcp_initialize(session, endpoint_obs.value or "", deadline_s)
return Probe(running=running, answering=answering, endpoint=endpoint_obs)
# ---- locations: each yields (candidate, resolution kind) in probe order -----------------
_Hit = tuple[Candidate, Kind]
_Locator = Callable[[AppDef, AppLocation, LookupContext | None], list[_Hit]]
def _present(presence: PresenceKind, path: str) -> bool:
if presence == "bundle":
return os.path.isdir(path) and os.path.isfile(os.path.join(path, "Contents", "Info.plist"))
return os.path.isfile(path)
def _hit(d: AppDef, source: str, path: str) -> _Hit:
present = os.path.isabs(path) and _present(d.presence, path)
return Candidate(path, source, present), "known_path"
def _version_order(path: str) -> list:
return [(1, int(part)) if part.isdigit() else (0, part) for part in re.split(r"(\d+)", path)]
def _path_hits(d: AppDef, loc: AppLocation, ctx: LookupContext | None) -> list[_Hit]:
target, source = _expand(loc.value), f"app:{d.app_id}"
not_found: list[_Hit] = [(Candidate(target, source, False), "known_path")]
if not os.path.isabs(target) or "%" in target or "$" in target:
return not_found
if "*" not in target:
return [_hit(d, source, target)]
# A `*` segment stands for a versioned folder (`Blender 5.2`); the highest version is tried first.
pattern = "*".join(glob.escape(part) for part in target.split("*"))
matches = sorted(glob.glob(pattern), key=_version_order, reverse=True)
return [_hit(d, source, match) for match in matches] or not_found
def _command_hits(d: AppDef, loc: AppLocation, ctx: LookupContext | None) -> list[_Hit]:
res = locate_command(loc.value, ctx)
return [(Candidate(c.value, f"app:{d.app_id}:{loc.kind}", c.present), "path_executable") for c in res.candidates]
def _uninstall_hits(d: AppDef, loc: AppLocation, ctx: LookupContext | None) -> list[_Hit]:
if sys.platform != "win32":
return []
# A UNC InstallLocation is skipped so a presence check never touches the network.
return [_hit(d, f"app:{d.app_id}:{loc.kind}", os.path.join(entry["InstallLocation"], loc.file))
for entry in _uninstall_entries(loc.value)
if entry.get("InstallLocation") and not entry["InstallLocation"].startswith(("\\\\", "//"))]
def _in_dirs(dirs: Callable[[], tuple[str, ...]]) -> _Locator:
def hits(d: AppDef, loc: AppLocation, ctx: LookupContext | None) -> list[_Hit]:
return [_hit(d, f"app:{d.app_id}:{loc.kind}", os.path.join(_expand(root), loc.value)) for root in dirs()]
return hits
@dataclass(frozen=True)
class LocationSpec:
"""A location kind a declaration names in a mapping. `only_on` is the one OS family it is valid
under (None: any), `key` names the mapping field holding what to find, `yields` is the presence
it finds, and `needs_file` says the mapping also carries a relative `file`."""
only_on: str | None
key: str
yields: PresenceKind
locate: _Locator
needs_file: bool = False
LOCATION_KINDS: dict[str, LocationSpec] = {
"command": LocationSpec(None, "name", "executable", _command_hits),
"uninstall_registry": LocationSpec("win32", "display_name_prefix", "executable", _uninstall_hits, needs_file=True),
"app_bundle": LocationSpec("darwin", "name", "bundle", _in_dirs(known_dirs.mac_application_dirs)),
"flatpak": LocationSpec("linux", "app_id", "executable", _in_dirs(known_dirs.flatpak_export_dirs)),
"snap": LocationSpec("linux", "name", "executable", _in_dirs(known_dirs.snap_bin_dirs)),
}
def _locator(kind: str) -> _Locator:
"""A plain string location is a `path`; every other kind comes from `LOCATION_KINDS`."""
return _path_hits if kind == "path" else LOCATION_KINDS[kind].locate
# ---- version sources -------------------------------------------------------------------
def _plist_version(bundle: str) -> Observation[str]:
with open(os.path.join(bundle, "Contents", "Info.plist"), "rb") as fh: # windows-footgun: ok — binary mode
info = plistlib.load(fh)
value = info.get("CFBundleShortVersionString") or info.get("CFBundleVersion")
if not value:
return Observation(CheckState.UNAVAILABLE, detail="no version key in Info.plist")
return Observation(CheckState.PRESENT, str(value))
def _pe_version(path: str) -> Observation[str]:
if sys.platform != "win32":
return Observation(CheckState.UNAVAILABLE, detail="pe_resource needs Windows")
import ctypes
from ctypes import wintypes
ver = ctypes.windll.version # type: ignore[attr-defined]
ver.GetFileVersionInfoSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
ver.GetFileVersionInfoSizeW.restype = wintypes.DWORD
size = ver.GetFileVersionInfoSizeW(path, None)
if not size:
return Observation(CheckState.UNAVAILABLE, detail="no version resource")
buf = ctypes.create_string_buffer(size)
ver.GetFileVersionInfoW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p]
ver.GetFileVersionInfoW.restype = wintypes.BOOL
if not ver.GetFileVersionInfoW(path, 0, size, buf):
return Observation(CheckState.ERROR, detail="GetFileVersionInfoW failed")
ptr = ctypes.c_void_p()
length = wintypes.UINT()
ver.VerQueryValueW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.UINT)]
ver.VerQueryValueW.restype = wintypes.BOOL
if not ver.VerQueryValueW(buf, "\\", ctypes.byref(ptr), ctypes.byref(length)) or not ptr.value:
return Observation(CheckState.UNAVAILABLE, detail="no fixed file info")
# VS_FIXEDFILEINFO: dwFileVersionMS at offset 8, dwFileVersionLS at offset 12.
ms = ctypes.cast(ptr.value + 8, ctypes.POINTER(wintypes.DWORD)).contents.value
ls = ctypes.cast(ptr.value + 12, ctypes.POINTER(wintypes.DWORD)).contents.value
return Observation(CheckState.PRESENT, f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}")
def _uninstall_values(entry: Any) -> dict[str, str]:
"""The uninstall entry's values that exist; any of them may be missing."""
import winreg
values: dict[str, str] = {}
for value_name in ("DisplayName", "DisplayVersion", "InstallLocation"):
try:
values[value_name] = str(winreg.QueryValueEx(entry, value_name)[0])
except OSError:
continue
return values
def _uninstall_entry(key: Any, index: int) -> dict[str, str]:
"""One child entry's values; an entry can vanish between EnumKey and OpenKey, which reads as no values."""
import winreg
try:
with winreg.OpenKey(key, winreg.EnumKey(key, index)) as entry:
return _uninstall_values(entry)
except OSError:
return {}
def _uninstall_entries(display_name_prefix: str) -> Iterator[dict[str, str]]:
"""Each uninstall entry whose `DisplayName` starts with the prefix, machine-wide before per-user."""
import winreg
roots = (
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
)
for hive, root in roots:
try:
with winreg.OpenKey(hive, root) as key:
count = winreg.QueryInfoKey(key)[0]
for i in range(count):
values = _uninstall_entry(key, i)
if values.get("DisplayName", "").startswith(display_name_prefix):
yield values
except OSError:
continue
def _uninstall_registry_version(display_name_prefix: str) -> Observation[str]:
if sys.platform != "win32":
return Observation(CheckState.UNAVAILABLE, detail="uninstall_registry needs Windows")
entry = next(_uninstall_entries(display_name_prefix), None)
if entry is None:
return Observation(CheckState.ABSENT, detail="no uninstall entry")
if "DisplayVersion" not in entry:
return Observation(CheckState.UNAVAILABLE, detail="entry has no DisplayVersion")
return Observation(CheckState.PRESENT, entry["DisplayVersion"])
# ---- liveness: the token stays inside this section -------------------------------------
@dataclass(frozen=True)
class _Session:
pid: int | None
url: str
token: str
def __repr__(self) -> str:
return f"_Session(pid={self.pid}, url={self.url!r}, token=<redacted>)"
def _read_server_json(path: str, d: AppDef) -> _Session | None:
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
pid = data.get(d.liveness_pid_key)
url = data.get(d.liveness_url_key)
token = data.get(d.liveness_token_key)
return _Session(
pid=pid if isinstance(pid, int) else None,
url=url if isinstance(url, str) else "",
token=token if isinstance(token, str) else "",
)
def _pid_alive(pid: int | None) -> Observation[bool]:
if pid is None or pid <= 0:
return Observation(CheckState.UNAVAILABLE, detail="no pid in runtime file")
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
k32.OpenProcess.restype = wintypes.HANDLE
handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if not handle:
return Observation(CheckState.ABSENT, False)
k32.CloseHandle.argtypes = [wintypes.HANDLE]
k32.CloseHandle(handle)
return Observation(CheckState.PRESENT, True)
try:
os.kill(pid, 0) # windows-footgun: ok — POSIX only, the win32 branch returned above
except ProcessLookupError:
return Observation(CheckState.ABSENT, False)
except PermissionError:
return Observation(CheckState.PRESENT, True)
return Observation(CheckState.PRESENT, True)
def _endpoint_observation(raw_url: str, fixed_path: str) -> Observation[str]:
"""Accept only a loopback http URL with a numeric port and no userinfo; the path is ours."""
if not raw_url:
return Observation(CheckState.UNAVAILABLE, detail="no url in runtime file")
try:
parts = urlsplit(raw_url)
hostname, port, username, password = parts.hostname, parts.port, parts.username, parts.password
except ValueError:
return Observation(CheckState.UNAVAILABLE, detail="malformed endpoint")
if parts.scheme != "http" or username or password:
return Observation(CheckState.UNAVAILABLE, detail="endpoint must be plain http without userinfo")
if hostname not in _LOOPBACK_HOSTS:
return Observation(CheckState.UNAVAILABLE, detail="endpoint must be loopback")
if port is None or not (1 <= port <= 65535):
return Observation(CheckState.UNAVAILABLE, detail="endpoint needs a numeric port")
return Observation(CheckState.PRESENT, f"http://{hostname}:{port}{fixed_path}")
def _mcp_initialize(session: _Session, endpoint: str, deadline_s: float) -> Observation[bool]:
"""One MCP `initialize` POST under one absolute deadline covering connect, headers, and body."""
import http.client
parts = urlsplit(endpoint)
body = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "hermes", "version": "probe"}},
}).encode()
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if session.token:
headers["Authorization"] = f"Bearer {session.token}"
deadline = time.monotonic() + max(0.1, deadline_s)
def remaining() -> float:
left = deadline - time.monotonic()
if left <= 0:
raise TimeoutError
return left
conn = http.client.HTTPConnection(parts.hostname or "127.0.0.1", parts.port or 80, timeout=remaining())
try:
conn.request("POST", parts.path or "/", body=body, headers=headers)
conn.sock.settimeout(remaining())
status = conn.getresponse().status
except TimeoutError:
return Observation(CheckState.ABSENT, False, f"no answer within {deadline_s:g} s")
except (OSError, http.client.HTTPException):
return Observation(CheckState.ABSENT, False, "connection refused or timed out")
finally:
conn.close()
if status in (200, 401, 403):
return Observation(CheckState.PRESENT, True, f"http {status}")
return Observation(CheckState.ABSENT, False, f"http {status}")