* 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.
440 lines
18 KiB
Python
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}")
|