From 48ed4e53646e3274f1789e77b6b5b8444c40eaa0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 18:59:35 +0530 Subject: [PATCH 01/35] feat(platform): hermes_platform.host with machine facts, runtime predicates, SoC recognizer Host facts and runtime predicates need one stdlib-only source that stays correct under architecture emulation. Hardware recognition reads host facts, not environment variables or GPU proxies. The package boundary gives later resolvers and catalog checks a shared foundation. Linear NS-920. --- hermes_constants.py | 71 +---------- hermes_platform/__init__.py | 4 + hermes_platform/catalog/__init__.py | 1 + hermes_platform/host/__init__.py | 4 + hermes_platform/host/facts.py | 176 +++++++++++++++++++++++++++ hermes_platform/host/products.py | 27 ++++ hermes_platform/host/runtime.py | 69 +++++++++++ hermes_platform/resolver/__init__.py | 1 + pyproject.toml | 4 +- 9 files changed, 287 insertions(+), 70 deletions(-) create mode 100644 hermes_platform/__init__.py create mode 100644 hermes_platform/catalog/__init__.py create mode 100644 hermes_platform/host/__init__.py create mode 100644 hermes_platform/host/facts.py create mode 100644 hermes_platform/host/products.py create mode 100644 hermes_platform/host/runtime.py create mode 100644 hermes_platform/resolver/__init__.py diff --git a/hermes_constants.py b/hermes_constants.py index 6aaacef5ea..7eb4c52dfa 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -13,6 +13,8 @@ from collections.abc import MutableMapping from contextvars import ContextVar, Token from pathlib import Path +from hermes_platform.host.runtime import _detect_container, is_container, is_termux, is_wsl # noqa: F401 + _profile_fallback_warned: bool = False _UNSET = object() _HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar("_HERMES_HOME_OVERRIDE", default=_UNSET) @@ -1387,27 +1389,6 @@ def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None: return result -def is_termux() -> bool: - """True inside Termux (Android): ``TERMUX_VERSION`` or the Termux-specific ``PREFIX`` path.""" - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) - - -_wsl_detected: bool | None = None - - -def is_wsl() -> bool: - """True inside WSL1/WSL2 (``microsoft`` marker in ``/proc/version``); cached per process.""" - global _wsl_detected - if _wsl_detected is None: - try: - with open("/proc/version", "r", encoding="utf-8") as f: - _wsl_detected = "microsoft" in f.read().lower() - except Exception: - _wsl_detected = False - return _wsl_detected - - def windows_path_to_wsl(path: str) -> str | None: """Convert a Windows drive path (``C:\\...``) to its ``/mnt//...`` form.""" match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip()) @@ -1435,54 +1416,6 @@ def translate_cwd_for_wsl_backend(cwd: str) -> str: return cwd -_container_detected: bool | None = None - - -def is_container() -> bool: - """True inside a container (Docker/Podman/LXC/Kubernetes markers); cached per process. - - See: NousResearch/hermes-agent#47111 - """ - global _container_detected - if _container_detected is None: - _container_detected = _detect_container() - return _container_detected - - -def _read_proc(path: str) -> str: - try: - with open(path, "r", encoding="utf-8") as f: - return f.read() - except OSError: - return "" - - -def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool: - content = _read_proc(path) - return any(marker in content for marker in markers) - - -def _detect_container() -> bool: - if ( - os.path.exists("/.dockerenv") - or os.path.exists("/run/.containerenv") - or os.environ.get("KUBERNETES_SERVICE_HOST") - or _proc_file_has_marker("/proc/1/cgroup", ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio")) - ): - return True - # cgroup v2: /proc/1/cgroup is just "0::/"; the runtime still shows in mountinfo — but ONLY on - # the root ("/") mount line. A host that merely *runs* containers exposes every container's - # overlay lowerdir (``lowerdir=/var/lib/containerd/...``) at non-root mount points, which a - # whole-file scan misread as "inside a container" and flipped subprocess HOME (#58135). - return _root_mount_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio")) - - -def _root_mount_has_marker(path: str, markers: tuple[str, ...]) -> bool: - """mountinfo field 5 (index 4) is the mount point; only the root ("/") line is the process's own rootfs.""" - root_lines = [line for line in _read_proc(path).splitlines() if len(f := line.split()) >= 5 and f[4] == "/"] - return any(marker in line for line in root_lines for marker in markers) - - def get_config_path() -> Path: """Return the path to ``config.yaml`` under HERMES_HOME.""" return get_hermes_home() / "config.yaml" diff --git a/hermes_platform/__init__.py b/hermes_platform/__init__.py new file mode 100644 index 0000000000..8f018e6a09 --- /dev/null +++ b/hermes_platform/__init__.py @@ -0,0 +1,4 @@ +"""Machine facts and resource resolution for every Hermes surface. + +Host facts describe the machine running this interpreter; resolution has no side effects. +""" diff --git a/hermes_platform/catalog/__init__.py b/hermes_platform/catalog/__init__.py new file mode 100644 index 0000000000..0e82da6a07 --- /dev/null +++ b/hermes_platform/catalog/__init__.py @@ -0,0 +1 @@ +"""Declarative application catalog and loader.""" diff --git a/hermes_platform/host/__init__.py b/hermes_platform/host/__init__.py new file mode 100644 index 0000000000..af8e0ed1e3 --- /dev/null +++ b/hermes_platform/host/__init__.py @@ -0,0 +1,4 @@ +"""Cached facts about the machine running this Python process. + +Facts use hardware sources without environment-variable input or subprocesses. +""" diff --git a/hermes_platform/host/facts.py b/hermes_platform/host/facts.py new file mode 100644 index 0000000000..83dec27531 --- /dev/null +++ b/hermes_platform/host/facts.py @@ -0,0 +1,176 @@ +"""Cached OS, architecture, and CPU facts for the current machine. + +Native architecture remains accurate when the Python process is emulated. +""" + +from __future__ import annotations + +import functools +import os +import platform +import sys + +_ARCH_NAMES = { + "amd64": "amd64", "x86_64": "amd64", "x64": "amd64", + "arm64": "arm64", "aarch64": "arm64", + "x86": "x86", "i386": "x86", "i686": "x86", +} +IMAGE_FILE_MACHINE = {0x8664: "amd64", 0xAA64: "arm64", 0x014C: "x86"} + + +def normalize_arch(raw: str | None) -> str: + """Return a canonical architecture name.""" + return _ARCH_NAMES.get((raw or "").strip().lower(), "unknown") + + +@functools.cache +def os_family() -> str: + """Return the current platform identifier.""" + return sys.platform + + +@functools.cache +def process_arch() -> str: + """Return the architecture of this Python process.""" + return normalize_arch(platform.machine()) + + +def windows_native_arch(*, wow64_native: int | None, machine: str, env_arch: str | None) -> str: + """Return the native architecture from Windows observations. + + Order matters: ``PROCESSOR_ARCHITECTURE`` reads ``AMD64`` inside an x64-emulated process + on ARM64 hardware, so the environment is consulted only when both APIs are unavailable. + """ + name = IMAGE_FILE_MACHINE.get(wow64_native or 0) + if name: + return name + normalized = normalize_arch(machine) + if normalized != "unknown": + return normalized + return normalize_arch(env_arch) + + +def _wow64_native_machine() -> int | None: + """Return the IsWow64Process2 native machine, or ``None`` when unavailable.""" + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + probe = getattr(kernel32, "IsWow64Process2", None) + if probe is None: + return None + # ctypes otherwise truncates the HANDLE pseudo-handle to c_int. + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.GetCurrentProcess.argtypes = [] + probe.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.USHORT), ctypes.POINTER(wintypes.USHORT)] + probe.restype = wintypes.BOOL + process_machine = wintypes.USHORT(0) + native_machine = wintypes.USHORT(0) + if not probe(kernel32.GetCurrentProcess(), ctypes.byref(process_machine), ctypes.byref(native_machine)): + return None + return native_machine.value or None + + +def _darwin_translated() -> bool: + """Return whether this process runs under Rosetta.""" + return _sysctl_int(b"sysctl.proc_translated") == 1 + + +@functools.cache +def native_arch() -> str: + """Return the machine architecture even when this process is emulated.""" + if sys.platform == "win32": + try: + wow64 = _wow64_native_machine() + except (OSError, AttributeError, TypeError, ValueError): + wow64 = None + env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE") + return windows_native_arch(wow64_native=wow64, machine=platform.machine(), env_arch=env_arch) + if sys.platform == "darwin" and process_arch() == "amd64" and _darwin_translated(): + return "arm64" + return process_arch() + + +_CPU_KEY = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0" + + +def parse_cpuinfo(text: str, *, device_tree_model: str = "") -> str: + """Return the first available CPU or device-tree model.""" + for key in ("model name", "Hardware"): + for line in text.splitlines(): + if line.lower().startswith(key.lower()) and ":" in line: + return line.split(":", 1)[1].strip() + return device_tree_model.replace("\0", "").replace("_", " ").strip() + + +def _read_text(path: str, limit: int = 65536) -> str: + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + return handle.read(limit) + except OSError: + return "" + + +def _winreg_str(subkey: str, name: str) -> str: + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey) as key: + value = winreg.QueryValueEx(key, name)[0] + except OSError: + return "" + return str(value).strip() + + +def _sysctl_int(name: bytes) -> int | None: + import ctypes + import ctypes.util + + libc = ctypes.CDLL(ctypes.util.find_library("c")) + value = ctypes.c_int(0) + size = ctypes.c_size_t(ctypes.sizeof(ctypes.c_int)) + if libc.sysctlbyname(name, ctypes.byref(value), ctypes.byref(size), None, 0) != 0: + return None + return value.value + + +def _sysctl_str(name: bytes) -> str: + import ctypes + import ctypes.util + + libc = ctypes.CDLL(ctypes.util.find_library("c")) + size = ctypes.c_size_t(0) + if libc.sysctlbyname(name, None, ctypes.byref(size), None, 0) != 0: + return "" + buffer = ctypes.create_string_buffer(size.value) + if libc.sysctlbyname(name, buffer, ctypes.byref(size), None, 0) != 0: + return "" + return buffer.value.decode(errors="replace").strip() + + +@functools.cache +def cpu_model() -> str: + """Return the CPU model, or an empty string when unavailable.""" + if sys.platform == "win32": + return _winreg_str(_CPU_KEY, "ProcessorNameString") + if sys.platform == "darwin": + return _sysctl_str(b"machdep.cpu.brand_string") + return parse_cpuinfo(_read_text("/proc/cpuinfo"), device_tree_model=_read_text("/proc/device-tree/model", 512)) + + +@functools.cache +def cpu_vendor() -> str: + """Return the CPU vendor, or an empty string when unavailable.""" + if sys.platform == "win32": + return _winreg_str(_CPU_KEY, "VendorIdentifier") + if sys.platform.startswith("linux"): + for line in _read_text("/proc/cpuinfo").splitlines(): + if line.lower().startswith("vendor_id") and ":" in line: + return line.split(":", 1)[1].strip() + return "" + + +def clear_caches() -> None: + """Clear every cached host fact.""" + for fact in (os_family, process_arch, native_arch, cpu_model, cpu_vendor): + fact.cache_clear() diff --git a/hermes_platform/host/products.py b/hermes_platform/host/products.py new file mode 100644 index 0000000000..273930ac68 --- /dev/null +++ b/hermes_platform/host/products.py @@ -0,0 +1,27 @@ +"""Vendor SoC recognizers derived from host facts. + +Recognition uses OS-reported CPU identity without environment-variable input. +""" + +from __future__ import annotations + +from hermes_platform.host import facts + +# Match the CPU string because the chassis vendor does not identify the SoC. +_NVIDIA_SOC_VENDOR = "NVIDIA" +_NVIDIA_SOC_MODEL_MARKERS = ("N1X", "SPARK") + + +def looks_like_nvidia_arm_soc(*, native_arch: str, cpu_model: str, cpu_vendor: str) -> bool: + """Return whether the supplied facts identify an NVIDIA ARM SoC.""" + if native_arch != "arm64": + return False + model = cpu_model.upper() + vendor_match = _NVIDIA_SOC_VENDOR in model or cpu_vendor.strip().upper() == _NVIDIA_SOC_VENDOR + return vendor_match and any(marker in model for marker in _NVIDIA_SOC_MODEL_MARKERS) + + +def is_nvidia_arm_soc() -> bool: + """Return whether this host has an NVIDIA ARM SoC.""" + return looks_like_nvidia_arm_soc( + native_arch=facts.native_arch(), cpu_model=facts.cpu_model(), cpu_vendor=facts.cpu_vendor()) diff --git a/hermes_platform/host/runtime.py b/hermes_platform/host/runtime.py new file mode 100644 index 0000000000..bcc7edbdd3 --- /dev/null +++ b/hermes_platform/host/runtime.py @@ -0,0 +1,69 @@ +"""Cached runtime-environment predicates for WSL, containers, and Termux.""" + +from __future__ import annotations + +import os + + +def is_termux() -> bool: + """Return whether this process runs inside Termux.""" + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + +_wsl_detected: bool | None = None + + +def is_wsl() -> bool: + """Return whether this process runs inside WSL.""" + global _wsl_detected + if _wsl_detected is None: + try: + with open("/proc/version", "r", encoding="utf-8") as f: + _wsl_detected = "microsoft" in f.read().lower() + except Exception: + _wsl_detected = False + return _wsl_detected + + +_container_detected: bool | None = None + + +def is_container() -> bool: + """Return whether this process runs inside a container.""" + global _container_detected + if _container_detected is None: + _container_detected = _detect_container() + return _container_detected + + +def _read_proc(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except OSError: + return "" + + +def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool: + content = _read_proc(path) + return any(marker in content for marker in markers) + + +def _detect_container() -> bool: + if ( + os.path.exists("/.dockerenv") + or os.path.exists("/run/.containerenv") + or os.environ.get("KUBERNETES_SERVICE_HOST") + or _proc_file_has_marker("/proc/1/cgroup", ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio")) + ): + return True + # Under cgroup v2, only the root mount identifies this process's container. + # Scanning all mountinfo lines misclassifies hosts that run containers (#58135). + return _root_mount_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio")) + + +def _root_mount_has_marker(path: str, markers: tuple[str, ...]) -> bool: + """Return whether the root mount contains any marker.""" + root_lines = [line for line in _read_proc(path).splitlines() if len(f := line.split()) >= 5 and f[4] == "/"] + return any(marker in line for line in root_lines for marker in markers) diff --git a/hermes_platform/resolver/__init__.py b/hermes_platform/resolver/__init__.py new file mode 100644 index 0000000000..e3c34bf9d2 --- /dev/null +++ b/hermes_platform/resolver/__init__.py @@ -0,0 +1 @@ +"""Resource owners and resolution helpers.""" diff --git a/pyproject.toml b/pyproject.toml index e38e5a8349..feb61e9480 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -606,9 +606,11 @@ youtube-transcript-api = false # and the drift broke installed wheels. Do not add the list back. [tool.setuptools.packages.find] -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*", "hermes_platform", "hermes_platform.*"] [tool.setuptools.package-data] +# hermes_platform/catalog ships apps.yaml (PR2); declared now so the wheel picks it up on arrival. +hermes_platform = ["catalog/*.yaml"] hermes_cli = ["observability/schemas/*.json", "data/*.json", "local_runtime/*.json"] # gateway/assets/ ships status_phrases.yaml and the Telegram BotFather # screenshot. Without this, sealed venvs (uv2nix) silently lose both — From 724783a67596d03ad7cfb4c14682921b258607b0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:07:42 +0530 Subject: [PATCH 02/35] refactor(cua): WSL detection through hermes_platform.host.runtime.is_wsl One runtime predicate keeps computer-use overlay policy from holding a separate WSL result. The shared result is cached per process. --- tools/computer_use/cua_backend.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index ff64fd8ea6..b0033f689e 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -19,6 +19,7 @@ import uuid from typing import Any, Dict, List, Optional from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_platform.host.runtime import is_wsl from tools.computer_use.backend import ActionResult, ComputerUseBackend from tools.computer_use.cua_backend_capture import _CaptureMixin from tools.computer_use.cua_backend_daemon import _EmbeddedCuaDaemon @@ -53,9 +54,7 @@ def _cua_no_overlay() -> bool: val = _computer_use_cfg().get("no_overlay") if val is not None or sys.platform != "linux": return bool(val) if val is not None else sys.platform == "darwin" - wsl = False - with contextlib.suppress(Exception), open("/proc/version", encoding="utf-8") as f: - wsl = "microsoft" in f.read().lower() + wsl = is_wsl() return wsl or not os.environ.get("DISPLAY") or ( # Linux/X11: the cursor overlay is a fullscreen, always-on-top, all-workspaces X11 window # (save-unders path). An unclean session end (agent interrupted mid-capture, stale target window) From 0bdf33b7d9f005bb2a98d6902e6ac60f0fca55c6 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:08:10 +0530 Subject: [PATCH 03/35] test: repoint WSL/container probe patches to hermes_platform.host.runtime (group B) The probe cache now belongs to hermes_platform.host.runtime. Call-site name patches stay where production resolves them. --- tests/acp_adapter/test_session.py | 8 ++++---- tests/tools/test_clipboard.py | 17 ++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/acp_adapter/test_session.py b/tests/acp_adapter/test_session.py index d7a95ecef9..d692569e89 100644 --- a/tests/acp_adapter/test_session.py +++ b/tests/acp_adapter/test_session.py @@ -46,7 +46,7 @@ class TestCreateSession: captured["task_id"] = task_id captured["overrides"] = overrides - monkeypatch.setattr("hermes_constants._wsl_detected", True) + monkeypatch.setattr("hermes_platform.host.runtime._wsl_detected", True) monkeypatch.setattr( "tools.terminal_tool.register_task_env_overrides", fake_register_task_env_overrides, @@ -204,7 +204,7 @@ class TestCreateSession: class TestWslCwdTranslation: def test_translate_acp_cwd_converts_windows_drive_path_when_wsl(self, monkeypatch): - monkeypatch.setattr("hermes_constants._wsl_detected", True) + monkeypatch.setattr("hermes_platform.host.runtime._wsl_detected", True) assert acp_session._translate_acp_cwd(r"E:\Projects\AI\paperclip") == "/mnt/e/Projects/AI/paperclip" @@ -213,7 +213,7 @@ class TestWslCwdTranslation: def test_fork_session_stores_translated_cwd_on_wsl(self, manager, monkeypatch): - monkeypatch.setattr("hermes_constants._wsl_detected", True) + monkeypatch.setattr("hermes_platform.host.runtime._wsl_detected", True) original = manager.create_session(cwd="/tmp/base") forked = manager.fork_session(original.session_id, cwd=r"D:\work\project") @@ -222,7 +222,7 @@ class TestWslCwdTranslation: assert forked.cwd == "/mnt/d/work/project" def test_update_cwd_stores_translated_cwd_on_wsl(self, manager, monkeypatch): - monkeypatch.setattr("hermes_constants._wsl_detected", True) + monkeypatch.setattr("hermes_platform.host.runtime._wsl_detected", True) state = manager.create_session(cwd="/tmp/old") updated = manager.update_cwd(state.session_id, cwd=r"C:\Users\foo\project") diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 0f50340037..ad99c915aa 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock, mock_open import pytest +from hermes_platform.host import runtime as host_runtime from hermes_cli.clipboard import ( save_clipboard_image, has_clipboard_image, @@ -173,18 +174,14 @@ class TestMacosClipboardFileUrl: class TestIsWsl: def setup_method(self): - # _is_wsl is hermes_constants.is_wsl; reset the function's own module - # globals so this stays stable even if hermes_constants was imported - # through a different module object earlier in a large xdist run. - import hermes_constants - hermes_constants._wsl_detected = None + # Reset the cache in the module that owns _is_wsl. + host_runtime._wsl_detected = None _is_wsl.__globals__["_wsl_detected"] = None def teardown_method(self): # Reset again after the test so we don't leak a cached value # (True/False) into whichever test the xdist worker runs next. - import hermes_constants - hermes_constants._wsl_detected = None + host_runtime._wsl_detected = None _is_wsl.__globals__["_wsl_detected"] = None @pytest.mark.parametrize("content, expected", [ @@ -343,8 +340,7 @@ class TestLinuxSave: """Test that _linux_save dispatches correctly to WSL → Wayland → X11.""" def setup_method(self): - import hermes_cli.clipboard as cb - cb._wsl_detected = None + host_runtime._wsl_detected = None def test_wsl_tried_first(self, tmp_path): dest = tmp_path / "out.png" @@ -458,8 +454,7 @@ class TestConvertToPng: class TestHasClipboardImage: def setup_method(self): - import hermes_cli.clipboard as cb - cb._wsl_detected = None + host_runtime._wsl_detected = None @pytest.mark.macos_only def test_macos_dispatch(self): From 35f4cc9874f1e09dab0da91bc416b81f22ac2226 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:08:35 +0530 Subject: [PATCH 04/35] test(platform): host facts, products, and import hygiene Contracts for native-architecture detection, hardware recognition, and the package's stdlib-only boundary. --- tests/hermes_platform/__init__.py | 0 tests/hermes_platform/test_facts.py | 98 ++++++++++++++++++++ tests/hermes_platform/test_import_hygiene.py | 33 +++++++ tests/hermes_platform/test_products.py | 24 +++++ 4 files changed, 155 insertions(+) create mode 100644 tests/hermes_platform/__init__.py create mode 100644 tests/hermes_platform/test_facts.py create mode 100644 tests/hermes_platform/test_import_hygiene.py create mode 100644 tests/hermes_platform/test_products.py diff --git a/tests/hermes_platform/__init__.py b/tests/hermes_platform/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/hermes_platform/test_facts.py b/tests/hermes_platform/test_facts.py new file mode 100644 index 0000000000..827bf56e9f --- /dev/null +++ b/tests/hermes_platform/test_facts.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + +from hermes_platform.host import facts + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("AMD64", "amd64"), + ("x86_64", "amd64"), + ("aarch64", "arm64"), + ("ARM64", "arm64"), + ("i386", "x86"), + ("weird", "unknown"), + ], +) +def test_normalize_arch(raw: str, expected: str) -> None: + assert facts.normalize_arch(raw) == expected + + +@pytest.mark.parametrize( + ("wow64_native", "machine", "env_arch", "expected"), + [ + (0xAA64, "AMD64", "x86", "arm64"), + (None, "x86_64", "ARM64", "amd64"), + (None, "unknown", "ARM64", "arm64"), + ], +) +def test_windows_native_arch_precedence( + wow64_native: int | None, + machine: str, + env_arch: str | None, + expected: str, +) -> None: + assert ( + facts.windows_native_arch( + wow64_native=wow64_native, + machine=machine, + env_arch=env_arch, + ) + == expected + ) + + +@pytest.mark.parametrize( + ("text", "device_tree_model", "expected"), + [ + ( + "Hardware : Fallback board\nmodel name : Primary CPU\n", + "Ignored_Model\0", + "Primary CPU", + ), + ("processor : 0\nHardware : ARM Board\n", "Ignored_Model\0", "ARM Board"), + ("processor : 0\n", "Vendor_Board_Name\0", "Vendor Board Name"), + ], +) +def test_parse_cpuinfo_fallbacks( + text: str, + device_tree_model: str, + expected: str, +) -> None: + assert facts.parse_cpuinfo(text, device_tree_model=device_tree_model) == expected + + +@pytest.fixture +def cleared_fact_caches(): + facts.clear_caches() + yield + facts.clear_caches() + + +@pytest.mark.windows_only +def test_windows_native_arch_matches_registry_identifier(cleared_fact_caches) -> None: + winreg = importlib.import_module("winreg") + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, facts._CPU_KEY) as key: + identifier = str(winreg.QueryValueEx(key, "Identifier")[0]).strip() + + expected = "arm64" if identifier.upper().startswith("ARMV8") else "amd64" + assert facts.native_arch() == expected + + +@pytest.mark.macos_only +def test_macos_live_cpu_facts(cleared_fact_caches) -> None: + assert facts.cpu_model() + assert facts.native_arch() in {"arm64", "amd64"} + + +@pytest.mark.linux_only +def test_linux_live_cpu_facts_match_cpuinfo(cleared_fact_caches) -> None: + cpuinfo = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace") + + assert facts.cpu_vendor() in cpuinfo + assert facts.cpu_model() in cpuinfo diff --git a/tests/hermes_platform/test_import_hygiene.py b/tests/hermes_platform/test_import_hygiene.py new file mode 100644 index 0000000000..50160c96ec --- /dev/null +++ b/tests/hermes_platform/test_import_hygiene.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + + +def test_host_modules_only_import_stdlib_and_hermes_platform() -> None: + root = Path(__file__).resolve().parents[2] + code = """ +import sys +before = set(sys.modules) +import hermes_platform.host.facts +import hermes_platform.host.runtime +import hermes_platform.host.products +new_top_levels = {name.partition('.')[0] for name in set(sys.modules) - before} +unexpected = sorted( + name + for name in new_top_levels + if name not in sys.stdlib_module_names and not name.startswith('hermes_platform') +) +assert not unexpected, unexpected +""" + env = os.environ.copy() + env["PYTHONPATH"] = "." + + subprocess.run( + [sys.executable, "-c", code], + cwd=root, + env=env, + check=True, + ) diff --git a/tests/hermes_platform/test_products.py b/tests/hermes_platform/test_products.py new file mode 100644 index 0000000000..833f564d50 --- /dev/null +++ b/tests/hermes_platform/test_products.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import pytest + +from hermes_platform.host import products +from hermes_platform.host.products import looks_like_nvidia_arm_soc + +_MARKER = products._NVIDIA_SOC_MODEL_MARKERS[0] +_VENDOR = products._NVIDIA_SOC_VENDOR + + +@pytest.mark.parametrize( + ("native_arch", "cpu_model", "cpu_vendor", "expected"), + [ + ("arm64", f"{_VENDOR} {_MARKER} (18-core CPU)", _VENDOR, True), + ("arm64", f"{_MARKER} (18-core CPU)", _VENDOR, True), + ("arm64", f"{_VENDOR} {_MARKER}", "", True), + ("amd64", f"{_VENDOR} {_MARKER}", _VENDOR, False), + ("arm64", "Snapdragon X Elite", "Qualcomm", False), + ("arm64", f"{_VENDOR} GPU only", _VENDOR, False), + ], +) +def test_looks_like_nvidia_arm_soc(native_arch: str, cpu_model: str, cpu_vendor: str, expected: bool) -> None: + assert looks_like_nvidia_arm_soc(native_arch=native_arch, cpu_model=cpu_model, cpu_vendor=cpu_vendor) is expected From a6b2364862450f6e29c2e0ed230ed5b6ac23b52a Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:09:05 +0530 Subject: [PATCH 05/35] test: repoint WSL/container probe patches to hermes_platform.host.runtime (group A) The probe cache now belongs to hermes_platform.host.runtime. Call-site name patches stay where production resolves them. --- tests/hermes_cli/test_desktop_wsl_gpu.py | 4 ++-- tests/hermes_cli/test_gateway_wsl.py | 3 ++- tests/test_hermes_constants.py | 13 ++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/hermes_cli/test_desktop_wsl_gpu.py b/tests/hermes_cli/test_desktop_wsl_gpu.py index f4b2cbd2ae..9f40c77e28 100644 --- a/tests/hermes_cli/test_desktop_wsl_gpu.py +++ b/tests/hermes_cli/test_desktop_wsl_gpu.py @@ -4,8 +4,8 @@ its GPU process (#106117) — and never overrides an explicit Mesa choice or fir import argparse from pathlib import Path -import hermes_constants from hermes_cli import main_desktop +from hermes_platform.host import runtime as host_runtime def _launch_env(monkeypatch, tmp_path, *, wsl: bool, dxg: bool, driver: bool) -> dict: @@ -18,7 +18,7 @@ def _launch_env(monkeypatch, tmp_path, *, wsl: bool, dxg: bool, driver: bool) -> dxg_path.touch() if driver: driver_path.write_bytes(b"\x7fELF") - monkeypatch.setattr(hermes_constants, "_wsl_detected", wsl) + monkeypatch.setattr(host_runtime, "_wsl_detected", wsl) monkeypatch.setattr(main_desktop, "_WSL_DXG_DEVICE", dxg_path) monkeypatch.setattr(main_desktop, "_WSL_D3D12_DRIVERS", (tmp_path / "missing_dri.so", driver_path)) monkeypatch.setattr(main_desktop, "_desktop_launch_options", lambda: ([], "auto", "auto", "auto")) diff --git a/tests/hermes_cli/test_gateway_wsl.py b/tests/hermes_cli/test_gateway_wsl.py index 6e7ff37932..bc10b33e52 100644 --- a/tests/hermes_cli/test_gateway_wsl.py +++ b/tests/hermes_cli/test_gateway_wsl.py @@ -8,6 +8,7 @@ import pytest import hermes_cli.gateway as gateway import hermes_constants +from hermes_platform.host import runtime as host_runtime # ============================================================================= @@ -19,7 +20,7 @@ class TestIsWsl: def setup_method(self): # Reset cached value between tests - hermes_constants._wsl_detected = None + host_runtime._wsl_detected = None def test_detects_wsl2(self): fake_content = ( diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 0d0881391d..ae2e813f60 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -8,6 +8,7 @@ from types import SimpleNamespace import pytest import hermes_constants +from hermes_platform.host import runtime as host_runtime from hermes_constants import ( VALID_REASONING_EFFORTS, agent_browser_runnable, @@ -376,7 +377,7 @@ class TestIsContainer: def _reset_cache(self, monkeypatch): """Reset the cached detection result before each test.""" - monkeypatch.setattr(hermes_constants, "_container_detected", None) + monkeypatch.setattr(host_runtime, "_container_detected", None) def test_detects_dockerenv(self, monkeypatch, tmp_path): """/.dockerenv triggers container detection.""" @@ -400,8 +401,6 @@ class TestIsContainer: """#58135: a host that merely RUNS containers exposes each container's overlay lowerdir (``lowerdir=/var/lib/containerd/...``) at non-root mount points; only the root ('/') line says whether *this* process lives in a runtime overlay.""" - from hermes_constants import _root_mount_has_marker - markers = ("kubepods", "containerd", "crio") host = tmp_path / "host" host.write_text( @@ -415,13 +414,13 @@ class TestIsContainer: "rw,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/9/fs\n" "2 1 0:51 / /proc rw,nosuid - proc proc rw\n" ) - assert _root_mount_has_marker(str(host), markers) is False - assert _root_mount_has_marker(str(container), markers) is True - assert _root_mount_has_marker(str(tmp_path / "missing"), markers) is False + assert host_runtime._root_mount_has_marker(str(host), markers) is False + assert host_runtime._root_mount_has_marker(str(container), markers) is True + assert host_runtime._root_mount_has_marker(str(tmp_path / "missing"), markers) is False def test_caches_result(self, monkeypatch): """Second call uses cached value without re-probing.""" - monkeypatch.setattr(hermes_constants, "_container_detected", True) + monkeypatch.setattr(host_runtime, "_container_detected", True) assert is_container() is True # Even if we make os.path.exists return False, cached value wins monkeypatch.setattr(os.path, "exists", lambda p: False) From 62aa4b7d0dcc8e663eb5b1f2d385823ded53394a Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:09:40 +0530 Subject: [PATCH 06/35] refactor(voice): WSL detection through hermes_platform.host.runtime.is_wsl One runtime predicate keeps WSL detection from diverging between call sites. The shared result is cached per process. --- tests/tools/test_voice_mode.py | 62 +++++--------------------- tests/tools/test_voice_wsl_pipewire.py | 15 ++----- tools/voice_mode.py | 21 +++------ 3 files changed, 22 insertions(+), 76 deletions(-) diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index e270973e1d..50c9e58918 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -10,18 +10,6 @@ from unittest.mock import MagicMock, patch import pytest -def _non_wsl_proc_version(real_open): - """Return an open() shim that makes host WSL detection deterministic.""" - def _fake_open(file, *args, **kwargs): - if file == "/proc/version": - from io import StringIO - - return StringIO("Linux test-kernel") - return real_open(file, *args, **kwargs) - - return _fake_open - - # ============================================================================ # Fixtures # ============================================================================ @@ -162,7 +150,7 @@ class TestDetectAudioEnvironment: monkeypatch.setattr("hermes_constants.is_container", lambda: False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: False) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -190,7 +178,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: False) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -198,7 +186,7 @@ class TestDetectAudioEnvironment: assert result["warnings"] == [] assert any("SSH" in n for n in result.get("notices", [])) - def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path): + def test_wsl_without_pulse_blocks_voice(self, monkeypatch): """WSL without PULSE_SERVER should block voice mode.""" monkeypatch.delenv("SSH_CLIENT", raising=False) monkeypatch.delenv("SSH_TTY", raising=False) @@ -208,18 +196,10 @@ class TestDetectAudioEnvironment: monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - proc_version = tmp_path / "proc_version" - proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2") + monkeypatch.setattr("tools.voice_mode.is_wsl", lambda: True) - _real_open = open - def _fake_open(f, *a, **kw): - if f == "/proc/version": - return _real_open(str(proc_version), *a, **kw) - return _real_open(f, *a, **kw) - - with patch("builtins.open", side_effect=_fake_open): - from tools.voice_mode import detect_audio_environment - result = detect_audio_environment() + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() assert result["available"] is False assert any("WSL" in w for w in result["warnings"]) @@ -1445,7 +1425,7 @@ class TestWSL2PowerShellFallback: m.wait = MagicMock(return_value=m.returncode) return m - with patch("tools.voice_mode._is_wsl2_env", return_value=True), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._import_audio", side_effect=ImportError), \ patch("tools.voice_mode.shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay", "sh") else (x if x.startswith("/") else None)), \ @@ -1491,13 +1471,7 @@ class TestWSL2PowerShellFallback: return f"C:\\Temp\\{wsl_path.split('/')[-1]}\n".encode() return b"" - def _fake_open(path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux Microsoft WSL2") - return open(path, *args, **kwargs) - - with patch("builtins.open", side_effect=_fake_open), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("powershell.exe", "ffmpeg", "ffplay") else None), \ patch("subprocess.check_output", side_effect=_capture_check_output), \ patch("subprocess.Popen", return_value=MagicMock(returncode=0, wait=lambda **k: 0)), \ @@ -1531,13 +1505,7 @@ class TestWSL2PowerShellFallback: m.wait.return_value = 0 return m - def _fake_open(path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux version 5.15.0-generic #72-Ubuntu") - return open(path, *args, **kwargs) - - with patch("builtins.open", side_effect=_fake_open), \ + with patch("tools.voice_mode.is_wsl", return_value=False), \ patch("tools.voice_mode._import_audio", side_effect=ImportError), \ patch("shutil.which", side_effect=lambda x: f"/bin/{x}" if x in ("ffplay", "aplay") else None), \ patch("subprocess.Popen", side_effect=_capture_popen), \ @@ -1558,12 +1526,6 @@ class TestWSLAudioEnvironmentGate: not be hard-blocked, but the recording/STT PulseAudio-bridge guidance must still be surfaced (as a non-blocking notice).""" - def _fake_open_wsl(self, path, *args, **kwargs): - if str(path) == "/proc/version": - import io - return io.StringIO("Linux version 5.15 Microsoft Standard WSL2") - return open(path, *args, **kwargs) - def test_wsl_no_pulse_but_powershell_available_not_hard_blocked(self, monkeypatch): from unittest.mock import patch from tools import voice_mode as vm @@ -1574,7 +1536,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._wsl_powershell_tts_available", return_value=True), \ patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \ patch("hermes_constants.is_container", return_value=False): @@ -1601,7 +1563,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("tools.voice_mode._wsl_powershell_tts_available", return_value=False), \ patch("tools.voice_mode._pulse_socket_reachable", return_value=False), \ patch("hermes_constants.is_container", return_value=False): @@ -1622,7 +1584,7 @@ class TestWSLAudioEnvironmentGate: monkeypatch.delenv(_ssh_var, raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) - with patch("builtins.open", side_effect=self._fake_open_wsl), \ + with patch("tools.voice_mode.is_wsl", return_value=True), \ patch("hermes_constants.is_container", return_value=False): result = vm.detect_audio_environment() diff --git a/tests/tools/test_voice_wsl_pipewire.py b/tests/tools/test_voice_wsl_pipewire.py index 395d401fbf..cc832582af 100644 --- a/tests/tools/test_voice_wsl_pipewire.py +++ b/tests/tools/test_voice_wsl_pipewire.py @@ -4,22 +4,15 @@ detect_audio_environment() honors forwarded audio (has_forwarded_audio = PULSE_SERVER or PIPEWIRE_REMOTE or a reachable socket) in the SSH and container blocks, but the WSL block previously checked only PULSE_SERVER — so a WSL user with PipeWire forwarding (PIPEWIRE_REMOTE) was wrongly blocked from voice mode. -These tests mock /proc/version so they reproduce the WSL path on any host. +These tests patch the voice module's WSL predicate so they reproduce the WSL path on any host. """ -import builtins -import io from unittest.mock import MagicMock -WSL = "Linux version 5.15.0-microsoft-standard-WSL2 (oe-user@oe-host)" +def _force_wsl(monkeypatch): + import tools.voice_mode as voice_mode -def _force_wsl(monkeypatch, content=WSL): - real_open = builtins.open - def fake_open(file, *a, **k): - if str(file) == "/proc/version": - return io.StringIO(content) - return real_open(file, *a, **k) - monkeypatch.setattr(builtins, "open", fake_open) + monkeypatch.setattr(voice_mode, "is_wsl", lambda: True) def _base(monkeypatch): diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 62690dade2..ff5814b013 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -23,8 +23,9 @@ from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) -from tools.voice_mode_transcript import _voice_config, is_voice_stop_phrase, is_whisper_hallucination from hermes_constants import is_termux as _is_termux_environment +from hermes_platform.host.runtime import is_wsl +from tools.voice_mode_transcript import _voice_config, is_voice_stop_phrase, is_whisper_hallucination # ── Recording parameters ── SAMPLE_RATE = 16000 # Whisper native rate @@ -310,7 +311,7 @@ def detect_audio_environment() -> dict: # WSL: the PowerShell/Media.SoundPlayer fallback only covers OUTPUT, so when # it is all that's available downgrade to a notice (recording guidance stays # visible, TTS-only usage isn't blocked). - if _is_wsl2_env(): + if is_wsl(): if has_forwarded_audio: notices.append("Running in WSL with a reachable PulseAudio/PipeWire sound server") elif _wsl_powershell_tts_available(): @@ -961,20 +962,10 @@ def stop_playback() -> None: sd.stop() -def _is_wsl2_env() -> bool: - """True inside WSL (Microsoft kernel signature in /proc/version); False on any error. - Module-level so tests can patch it instead of ``builtins.open``.""" - try: - with open("/proc/version", encoding="utf-8", errors="replace") as _fv: - return "microsoft" in _fv.read().lower() - except OSError: - return False - - def _wsl_powershell_tts_available() -> bool: """WSL2 PowerShell TTS fallback usable. OUTPUT only (Media.SoundPlayer on the host) — recording still needs a PulseAudio bridge, so callers keep surfacing that guidance.""" - return bool(_is_wsl2_env() and shutil.which("powershell.exe") and shutil.which("ffmpeg")) + return bool(is_wsl() and shutil.which("powershell.exe") and shutil.which("ffmpeg")) def play_audio_file(file_path: str) -> bool: @@ -1000,7 +991,7 @@ def _play_wav_via_sounddevice(file_path: str) -> bool: # ~100 ms to stabilise and the small default blocksize worsens # clock-adjustment jitter (microsoft/wslg#1257). blocksize = 0 # default (auto) - if _is_wsl2_env(): + if is_wsl(): fade_samples = int(0.1 * sample_rate) audio_float = audio_data.astype(np.float64) audio_float[:fade_samples] *= np.linspace(0.0, 1.0, fade_samples, dtype=np.float64) @@ -1023,7 +1014,7 @@ def _wsl_powershell_player_cmd(file_path: str) -> Optional[List[str]]: ffplay/aplay have no device, but Media.SoundPlayer on the host does: convert to a uniquely-named WAV in Windows %TEMP% (concurrent TTS must not collide), play, always delete, and re-raise the ORIGINAL exit status past the cleanup (rm -f exits 0).""" - if not (shutil.which("powershell.exe") and shutil.which("ffmpeg") and _is_wsl2_env()): + if not (shutil.which("powershell.exe") and shutil.which("ffmpeg") and is_wsl()): return None try: import uuid From 70aa33339c95ed939ab2a95dbac7f3434eba900c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:09:46 +0530 Subject: [PATCH 07/35] test: resolution ratchet for bare which and known-path tables, fully allowlisted A checked-in baseline blocks new resource lookups outside hermes_platform while existing owners migrate. --- tests/data/resolution_allowlist.json | 707 +++++++++++++++++++++++ tests/test_managed_runtime_resolution.py | 145 +++++ 2 files changed, 852 insertions(+) create mode 100644 tests/data/resolution_allowlist.json diff --git a/tests/data/resolution_allowlist.json b/tests/data/resolution_allowlist.json new file mode 100644 index 0000000000..97c65c9a90 --- /dev/null +++ b/tests/data/resolution_allowlist.json @@ -0,0 +1,707 @@ +[ + { + "path": "agent/anthropic_adapter.py", + "symbol": "", + "kind": "known_path_table" + }, + { + "path": "agent/anthropic_adapter.py", + "symbol": "_claude_code_candidates", + "kind": "bare_which" + }, + { + "path": "agent/anthropic_credentials.py", + "symbol": "run_oauth_setup_token", + "kind": "bare_which" + }, + { + "path": "agent/lsp/cli.py", + "symbol": "_backend_warnings", + "kind": "bare_which" + }, + { + "path": "agent/lsp/install.py", + "symbol": "_do_install", + "kind": "bare_which" + }, + { + "path": "agent/lsp/install.py", + "symbol": "_existing_binary", + "kind": "bare_which" + }, + { + "path": "agent/lsp/install.py", + "symbol": "_install_go", + "kind": "bare_which" + }, + { + "path": "agent/lsp/servers.py", + "symbol": "_which", + "kind": "bare_which" + }, + { + "path": "agent/proxy_sources/iron_proxy.py", + "symbol": "_verify_checksums_signature", + "kind": "bare_which" + }, + { + "path": "agent/proxy_sources/iron_proxy.py", + "symbol": "ensure_ca_cert", + "kind": "bare_which" + }, + { + "path": "agent/proxy_sources/iron_proxy.py", + "symbol": "find_iron_proxy", + "kind": "bare_which" + }, + { + "path": "agent/secret_sources/bitwarden.py", + "symbol": "find_bws", + "kind": "bare_which" + }, + { + "path": "agent/secret_sources/onepassword.py", + "symbol": "find_op", + "kind": "bare_which" + }, + { + "path": "agent/vault_backends/base.py", + "symbol": "is_installed", + "kind": "bare_which" + }, + { + "path": "agent/vault_backends/bitwarden.py", + "symbol": "BitwardenLoginBackend._bw", + "kind": "bare_which" + }, + { + "path": "cron/scheduler_delivery.py", + "symbol": "_deliver_to_bot_chat", + "kind": "bare_which" + }, + { + "path": "cron/scheduler_script.py", + "symbol": "_script_argv", + "kind": "bare_which" + }, + { + "path": "gateway/platforms/base.py", + "symbol": "transcode_to_ogg_opus", + "kind": "bare_which" + }, + { + "path": "gateway/platforms/signal.py", + "symbol": "_remux_aac_to_m4a", + "kind": "bare_which" + }, + { + "path": "gateway/platforms/webhook_filters.py", + "symbol": "WebhookRouteProcessor.run_route_script", + "kind": "bare_which" + }, + { + "path": "gateway/platforms/whatsapp_cloud.py", + "symbol": "", + "kind": "bare_which" + }, + { + "path": "gateway/run.py", + "symbol": "_resolve_hermes_bin", + "kind": "bare_which" + }, + { + "path": "gateway/run_shutdown.py", + "symbol": "GatewayShutdownMixin._launch_detached_restart_command", + "kind": "bare_which" + }, + { + "path": "gateway/shutdown_forensics.py", + "symbol": "spawn_async_diagnostic", + "kind": "bare_which" + }, + { + "path": "gateway/slash_commands.py", + "symbol": "_spawn_detached_update", + "kind": "bare_which" + }, + { + "path": "hermes_cli/_early_recovery.py", + "symbol": "_find_uv_binary", + "kind": "bare_which" + }, + { + "path": "hermes_cli/_subprocess_compat.py", + "symbol": "resolve_node_command", + "kind": "bare_which" + }, + { + "path": "hermes_cli/auth.py", + "symbol": "_external_process_spec", + "kind": "bare_which" + }, + { + "path": "hermes_cli/commands_completion.py", + "symbol": "SlashCommandCompleter._get_project_files", + "kind": "bare_which" + }, + { + "path": "hermes_cli/config.py", + "symbol": "edit_config", + "kind": "bare_which" + }, + { + "path": "hermes_cli/copilot_auth.py", + "symbol": "_gh_cli_candidates", + "kind": "bare_which" + }, + { + "path": "hermes_cli/dep_ensure.py", + "symbol": "", + "kind": "bare_which" + }, + { + "path": "hermes_cli/dep_ensure.py", + "symbol": "_has_system_browser", + "kind": "bare_which" + }, + { + "path": "hermes_cli/dep_ensure.py", + "symbol": "ensure_dependency", + "kind": "bare_which" + }, + { + "path": "hermes_cli/doctor_live.py", + "symbol": "_browser_available", + "kind": "bare_which" + }, + { + "path": "hermes_cli/doctor_platform.py", + "symbol": "_macos_desktop_dr", + "kind": "bare_which" + }, + { + "path": "hermes_cli/doctor_tools.py", + "symbol": "_safe_which", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_append_node_dir_for_service", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_build_wsl_interop_paths", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_ensure_linger_enabled", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_get_parent_pid", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_preflight_user_systemd", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_setup_signal", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_windows_process_listing", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "_windows_scheduled_task_state", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "get_systemd_linger_status", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway.py", + "symbol": "supports_systemd_services", + "kind": "bare_which" + }, + { + "path": "hermes_cli/gateway_windows.py", + "symbol": "_exec_schtasks", + "kind": "bare_which" + }, + { + "path": "hermes_cli/git_credentials.py", + "symbol": "_credential_fill", + "kind": "bare_which" + }, + { + "path": "hermes_cli/git_credentials.py", + "symbol": "_github_token", + "kind": "bare_which" + }, + { + "path": "hermes_cli/kanban_db_dispatch.py", + "symbol": "_resolve_hermes_argv", + "kind": "bare_which" + }, + { + "path": "hermes_cli/linux_desktop_entry.py", + "symbol": "_refresh_hicolor_cache", + "kind": "bare_which" + }, + { + "path": "hermes_cli/linux_desktop_entry.py", + "symbol": "refresh_desktop_databases", + "kind": "bare_which" + }, + { + "path": "hermes_cli/local_runtime/hardware.py", + "symbol": "_nvidia_smi_path", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main.py", + "symbol": "_exec_in_container", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_linux_sandbox_fixup", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_linux_userns_sandbox_available", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_macos_has_valid_real_signature", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_macos_local_codesign", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_macos_relaunchable_fixup", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_desktop.py", + "symbol": "_desktop_macos_setup_tcc_identity", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_install_repair.py", + "symbol": "_resolve_node_runtime_npm", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_tui_launch.py", + "symbol": "_apply_tui_python_env", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_tui_launch.py", + "symbol": "_ensure_tui_node", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_tui_launch.py", + "symbol": "_restore_tui_workspace", + "kind": "bare_which" + }, + { + "path": "hermes_cli/main_web_build.py", + "symbol": "_nixos_build_env", + "kind": "bare_which" + }, + { + "path": "hermes_cli/managed_uv.py", + "symbol": "_macos_sign_managed_python", + "kind": "bare_which" + }, + { + "path": "hermes_cli/mcp_app_detection.py", + "symbol": "_Scan.path_candidates", + "kind": "bare_which" + }, + { + "path": "hermes_cli/mcp_catalog.py", + "symbol": "_do_git_install", + "kind": "bare_which" + }, + { + "path": "hermes_cli/nous_subscription.py", + "symbol": "_has_agent_browser", + "kind": "bare_which" + }, + { + "path": "hermes_cli/plugins_cmd.py", + "symbol": "_resolve_git_executable", + "kind": "bare_which" + }, + { + "path": "hermes_cli/profiles.py", + "symbol": "create_wrapper_script", + "kind": "bare_which" + }, + { + "path": "hermes_cli/relaunch.py", + "symbol": "resolve_hermes_bin", + "kind": "bare_which" + }, + { + "path": "hermes_cli/session_lost_and_found.py", + "symbol": "find_sqlite3_cli", + "kind": "bare_which" + }, + { + "path": "hermes_cli/setup_terminal.py", + "symbol": "_setup_backend_singularity", + "kind": "bare_which" + }, + { + "path": "hermes_cli/setup_tts.py", + "symbol": "_install_neutts_deps", + "kind": "bare_which" + }, + { + "path": "hermes_cli/stdio.py", + "symbol": "_default_windows_editor", + "kind": "bare_which" + }, + { + "path": "hermes_cli/tools_config_cua.py", + "symbol": "_repair_cua_driver_autostart_windows", + "kind": "bare_which" + }, + { + "path": "hermes_cli/tools_config_cua.py", + "symbol": "install_cua_driver", + "kind": "bare_which" + }, + { + "path": "hermes_cli/tools_config_post_setup.py", + "symbol": "_ensure_browser_use_cli", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_abort_recovery.py", + "symbol": "_run_fresh_recovery_process", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_abort_recovery.py", + "symbol": "_serve_unit_recovery_available", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_cmd_deps.py", + "symbol": "_ensure_uv_for_termux", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_cmd_deps.py", + "symbol": "_update_node_dependencies", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_cmd_maint.py", + "symbol": "_refresh_cua_driver_after_update", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_restart_recovery.py", + "symbol": "_systemctl_scopes", + "kind": "bare_which" + }, + { + "path": "hermes_cli/update_restart_recovery.py", + "symbol": "_systemd_verified_active", + "kind": "bare_which" + }, + { + "path": "hermes_cli/web_git.py", + "symbol": "_gh", + "kind": "bare_which" + }, + { + "path": "hermes_cli/web_routers/git.py", + "symbol": "_probe_gh_auth", + "kind": "bare_which" + }, + { + "path": "hermes_cli/web_routers/tools.py", + "symbol": "_probe_singularity_backend", + "kind": "bare_which" + }, + { + "path": "hermes_constants.py", + "symbol": "find_node_executable_on_path", + "kind": "bare_which" + }, + { + "path": "tools/bot_relay.py", + "symbol": "_hermes_cli", + "kind": "bare_which" + }, + { + "path": "tools/browser_lightpanda.py", + "symbol": "find_lightpanda_binary", + "kind": "bare_which" + }, + { + "path": "tools/browser_tool_install.py", + "symbol": "_agent_browser_candidates", + "kind": "bare_which" + }, + { + "path": "tools/browser_tool_install.py", + "symbol": "_chromium_installed", + "kind": "bare_which" + }, + { + "path": "tools/browser_tool_install.py", + "symbol": "_find_agent_browser", + "kind": "bare_which" + }, + { + "path": "tools/browser_tool_install.py", + "symbol": "_resolve_npx_bin", + "kind": "bare_which" + }, + { + "path": "tools/browser_use_cli.py", + "symbol": "_find_cli", + "kind": "bare_which" + }, + { + "path": "tools/browser_use_cli.py", + "symbol": "install_cli", + "kind": "bare_which" + }, + { + "path": "tools/checkpoint_manager.py", + "symbol": "CheckpointManager.ensure_checkpoint", + "kind": "bare_which" + }, + { + "path": "tools/checkpoint_manager_profile_rename.py", + "symbol": "migrate_profile_checkpoint_projects", + "kind": "bare_which" + }, + { + "path": "tools/computer_use/cua_backend_daemon.py", + "symbol": "_validate_cua_driver_app_signature", + "kind": "bare_which" + }, + { + "path": "tools/computer_use/cua_backend_driver.py", + "symbol": "resolve_cua_driver_cmd", + "kind": "bare_which" + }, + { + "path": "tools/delegate_tool_config.py", + "symbol": "_require_pinned_command", + "kind": "bare_which" + }, + { + "path": "tools/env_probe.py", + "symbol": "_build_probe_line", + "kind": "bare_which" + }, + { + "path": "tools/env_probe.py", + "symbol": "_py_out", + "kind": "bare_which" + }, + { + "path": "tools/environments/docker.py", + "symbol": "find_docker", + "kind": "bare_which" + }, + { + "path": "tools/environments/local.py", + "symbol": "_find_bash", + "kind": "bare_which" + }, + { + "path": "tools/environments/local.py", + "symbol": "_resolve_hermes_bin_dir", + "kind": "bare_which" + }, + { + "path": "tools/environments/local.py", + "symbol": "_windows_bash_candidates", + "kind": "bare_which" + }, + { + "path": "tools/environments/local_gitbash_probe.py", + "symbol": "_mandatory_aslr_enabled", + "kind": "bare_which" + }, + { + "path": "tools/environments/singularity.py", + "symbol": "_find_singularity_executable", + "kind": "bare_which" + }, + { + "path": "tools/environments/ssh.py", + "symbol": "_ensure_ssh_available", + "kind": "bare_which" + }, + { + "path": "tools/lazy_deps.py", + "symbol": "_uv_binary", + "kind": "bare_which" + }, + { + "path": "tools/mcp_tool_config.py", + "symbol": "_resolve_stdio_command", + "kind": "bare_which" + }, + { + "path": "tools/mcp_tool_config.py", + "symbol": "_which_with_config_pathext", + "kind": "bare_which" + }, + { + "path": "tools/process_registry.py", + "symbol": "_build_systemd_scope_argv", + "kind": "bare_which" + }, + { + "path": "tools/process_registry.py", + "symbol": "_stop_systemd_unit", + "kind": "bare_which" + }, + { + "path": "tools/process_registry.py", + "symbol": "_systemd_run_user_scope_available", + "kind": "bare_which" + }, + { + "path": "tools/read_extract.py", + "symbol": "_pdf_page_texts", + "kind": "bare_which" + }, + { + "path": "tools/skillevaluator_scan.py", + "symbol": "run_tier1_scan", + "kind": "bare_which" + }, + { + "path": "tools/skillevaluator_scan.py", + "symbol": "scanner_available", + "kind": "bare_which" + }, + { + "path": "tools/terminal_tool_backends.py", + "symbol": "", + "kind": "bare_which" + }, + { + "path": "tools/tirith_security.py", + "symbol": "_find_local_tirith", + "kind": "bare_which" + }, + { + "path": "tools/tirith_security.py", + "symbol": "_is_install_failed_on_disk", + "kind": "bare_which" + }, + { + "path": "tools/tirith_security.py", + "symbol": "_resolve_locally", + "kind": "bare_which" + }, + { + "path": "tools/tirith_security.py", + "symbol": "_verify_cosign", + "kind": "bare_which" + }, + { + "path": "tools/tirith_security.py", + "symbol": "_verify_release_provenance", + "kind": "bare_which" + }, + { + "path": "tools/transcription_audio.py", + "symbol": "_convert_caf_to_wav", + "kind": "bare_which" + }, + { + "path": "tools/transcription_audio.py", + "symbol": "_find_binary", + "kind": "bare_which" + }, + { + "path": "tools/tts_tool_delivery.py", + "symbol": "_concat_audio_files", + "kind": "bare_which" + }, + { + "path": "tools/tts_tool_delivery.py", + "symbol": "_finalize_wav_output", + "kind": "bare_which" + }, + { + "path": "tools/tts_tool_delivery.py", + "symbol": "_write_wav_bytes_as", + "kind": "bare_which" + }, + { + "path": "tools/vision_tools_image_prep.py", + "symbol": "_rasterize_svg_to_png", + "kind": "bare_which" + }, + { + "path": "tools/voice_mode.py", + "symbol": "_play_audio_file_impl", + "kind": "bare_which" + }, + { + "path": "tools/voice_mode.py", + "symbol": "_termux_api_app_installed", + "kind": "bare_which" + }, + { + "path": "tools/voice_mode.py", + "symbol": "_termux_microphone_command", + "kind": "bare_which" + }, + { + "path": "tools/voice_mode.py", + "symbol": "_wsl_powershell_player_cmd", + "kind": "bare_which" + }, + { + "path": "tools/voice_mode.py", + "symbol": "_wsl_powershell_tts_available", + "kind": "bare_which" + }, + { + "path": "tools/working_diff.py", + "symbol": "collect_working_diff", + "kind": "bare_which" + }, + { + "path": "tui_gateway/methods_prompt.py", + "symbol": "_", + "kind": "bare_which" + } +] diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index 47c0e91de8..86d4455317 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -27,12 +27,23 @@ from __future__ import annotations import ast import functools +import json import os from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_MARKER = "" +_RESOLUTION_ALLOWLIST_PATH = REPO_ROOT / "tests/data/resolution_allowlist.json" +_KNOWN_PATH_FRAGMENTS = ( + ".local/bin", + ".cargo/bin", + "/opt/homebrew/bin", + "LOCALAPPDATA", + "scoop", + "WinGet", +) # Runtimes Hermes provisions into HERMES_HOME and must therefore resolve # through a managed-aware helper rather than PATH. @@ -125,6 +136,88 @@ def _iter_which_calls(tree: ast.AST): yield first.value, node.lineno +class _ResolutionSiteVisitor(ast.NodeVisitor): + """Collect bare PATH lookups and known-path tables by enclosing symbol.""" + + def __init__(self, tree: ast.Module) -> None: + self._scope: list[tuple[str, bool]] = [] + self._shutil_aliases = {"shutil"} + self._which_aliases: set[str] = set() + self.sites: set[tuple[str, str]] = set() + self.visit(tree) + + @property + def _symbol(self) -> str: + if not any(is_function for _, is_function in self._scope): + return MODULE_MARKER + parts: list[str] = [] + for index, (name, _) in enumerate(self._scope): + if index and self._scope[index - 1][1]: + parts.append("") + parts.append(name) + return ".".join(parts) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + if alias.name == "shutil": + self._shutil_aliases.add(alias.asname or alias.name) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module == "shutil": + for alias in node.names: + if alias.name == "which": + self._which_aliases.add(alias.asname or alias.name) + + def _visit_scope(self, node: ast.AST, name: str, *, is_function: bool) -> None: + self._scope.append((name, is_function)) + self.generic_visit(node) + self._scope.pop() + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._visit_scope(node, node.name, is_function=False) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_scope(node, node.name, is_function=True) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_scope(node, node.name, is_function=True) + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + is_shutil_which = ( + isinstance(func, ast.Attribute) + and func.attr == "which" + and isinstance(func.value, ast.Name) + and func.value.id in self._shutil_aliases + ) + is_imported_which = isinstance(func, ast.Name) and func.id in self._which_aliases + if is_shutil_which or is_imported_which: + self.sites.add((self._symbol, "bare_which")) + self.generic_visit(node) + + def visit_List(self, node: ast.List) -> None: + self._visit_path_table(node) + self.generic_visit(node) + + def visit_Tuple(self, node: ast.Tuple) -> None: + self._visit_path_table(node) + self.generic_visit(node) + + def _visit_path_table(self, node: ast.List | ast.Tuple) -> None: + strings = [ + element.value + for element in node.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + ] + fragments = { + fragment + for fragment in _KNOWN_PATH_FRAGMENTS + if any(fragment in value for value in strings) + } + if len(fragments) >= 2: + self.sites.add((self._symbol, "known_path_table")) + + def _source_files() -> list[Path]: files: list[Path] = [] # os.walk instead of Path.rglob: rglob raises FileNotFoundError when a @@ -178,6 +271,58 @@ def _findings() -> list[tuple[str, str, int]]: return found +def _resolution_sites() -> set[tuple[str, str, str]]: + """Return (path, symbol, kind) for the resolution sites under review.""" + sites: set[tuple[str, str, str]] = set() + for path in _source_files(): + rel = path.relative_to(REPO_ROOT).as_posix() + if rel == "hermes_platform" or rel.startswith("hermes_platform/"): + continue + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + try: + tree = ast.parse(source) + except SyntaxError: + continue + visitor = _ResolutionSiteVisitor(tree) + sites.update((rel, symbol, kind) for symbol, kind in visitor.sites) + return sites + + +def _resolution_allowlist() -> set[tuple[str, str, str]]: + rows = json.loads(_RESOLUTION_ALLOWLIST_PATH.read_text(encoding="utf-8")) + return {(row["path"], row["symbol"], row["kind"]) for row in rows} + + +def _format_resolution_sites(sites: set[tuple[str, str, str]]) -> str: + return "\n".join( + f" {path}::{symbol} ({kind})" for path, symbol, kind in sorted(sites) + ) + + +def test_bare_which_and_known_path_tables_are_allowlisted(): + """New resolution sites must use the platform layer or be reviewed.""" + unlisted = _resolution_sites() - _resolution_allowlist() + + assert not unlisted, ( + "Unreviewed command resolution sites:\n" + + _format_resolution_sites(unlisted) + + "\nuse a hermes_platform resolver or add a justified allowlist row" + ) + + +def test_resolution_allowlist_has_no_stale_rows(): + """Remove bootstrap rows as their call sites move to hermes_platform.""" + stale = _resolution_allowlist() - _resolution_sites() + + assert not stale, ( + "Resolution allowlist rows no longer match a source site; remove them:\n" + + _format_resolution_sites(stale) + ) + + def test_no_unreviewed_bare_managed_runtime_lookups(): """Every bare which() for a managed runtime is a reviewed exemption.""" unexpected = [ From a0f0ab8f36c9be7d378bc35977b9c575f40ddea8 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:11:10 +0530 Subject: [PATCH 08/35] refactor(platform): one native_arch for local-runtime assets, desktop PE gate, managed Node The three architecture readers could disagree under emulation. One shared native-architecture result prevents wrong asset selection. --- hermes_cli/local_runtime/binaries.py | 12 ++-- hermes_cli/main_desktop.py | 59 +++---------------- hermes_constants.py | 5 +- .../hermes_cli/test_desktop_exe_integrity.py | 8 +++ 4 files changed, 22 insertions(+), 62 deletions(-) diff --git a/hermes_cli/local_runtime/binaries.py b/hermes_cli/local_runtime/binaries.py index a7b98c9296..53e3dbc485 100644 --- a/hermes_cli/local_runtime/binaries.py +++ b/hermes_cli/local_runtime/binaries.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable +from hermes_platform.host import facts + logger = logging.getLogger(__name__) @@ -92,16 +94,10 @@ def installed_tags() -> list[str]: def _host_os_arch() -> tuple[str, str]: - """(os, arch) normalized to release-asset vocabulary. PITFALL: PROCESSOR_ARCHITECTURE lies - under x64 emulation on ARM64 Windows, and platform.machine() reads the same env on some - Pythons — so on Windows prefer PROCESSOR_IDENTIFIER's text when present.""" + """Return the host OS and architecture in release-asset vocabulary.""" system = platform.system().lower() os_name = {"windows": "win", "darwin": "macos", "linux": "ubuntu"}.get(system, system) - arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64" - if os_name == "win": - ident = os.environ.get("PROCESSOR_IDENTIFIER", "").lower() - if "armv8" in ident or "arm " in ident: - arch = "arm64" + arch = "arm64" if facts.native_arch() == "arm64" else "x64" return os_name, arch diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index f02cc2956e..77eae183b7 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -9,6 +9,7 @@ import contextlib import argparse import hashlib import os +import platform import re import shlex import shutil @@ -21,6 +22,7 @@ import time as _time_mod from pathlib import Path from typing import Optional from hermes_cli.desktop_console import desktop_console_output, desktop_launch_notice +from hermes_platform.host import facts from hermes_cli.main_tui_launch import _npm_lifecycle_env from hermes_cli.main_web_build import ( _hash_source_tree, _nixos_build_env, _stamp_is_current, _write_build_stamp) @@ -288,34 +290,6 @@ def _kernel32(): return ctypes.WinDLL("kernel32", use_last_error=True) -def _windows_native_machine_from_iswow64() -> Optional[str]: - """IsWow64Process2's OS-native machine, or None. HANDLE types are bound explicitly: ctypes' - default ``c_int`` truncates the ``(HANDLE)-1`` pseudo-handle → ``ERROR_INVALID_HANDLE`` on Win64. - - ctypes defaults ``GetCurrentProcess``'s restype to ``c_int``, so the current-process pseudo-handle - ``(HANDLE)-1`` is truncated to ``0xFFFFFFFF`` and zero-extended into a 64-bit invalid handle. On Win64 - that makes ``IsWow64Process2`` fail with ``ERROR_INVALID_HANDLE`` (6), which is exactly the residual - Windows-on-ARM failure after #71218: the gate fell through to ``PROCESSOR_ARCHITECTURE=AMD64`` (the - emulated process arch) and rejected a correctly-built ARM64 ``Hermes.exe``. Binding - ``restype``/``argtypes`` to ``wintypes.HANDLE`` keeps the full ``0xFFFFFFFFFFFFFFFF`` pseudo-handle. - """ - import ctypes - from ctypes import wintypes - kernel32 = _kernel32() - kernel32.GetCurrentProcess.restype = wintypes.HANDLE - kernel32.GetCurrentProcess.argtypes = [] - kernel32.IsWow64Process2.argtypes = [ - wintypes.HANDLE, ctypes.POINTER(wintypes.USHORT), ctypes.POINTER(wintypes.USHORT)] - kernel32.IsWow64Process2.restype = wintypes.BOOL - - process_machine = wintypes.USHORT(0) - native_machine = wintypes.USHORT(0) - if not kernel32.IsWow64Process2( - kernel32.GetCurrentProcess(), ctypes.byref(process_machine), ctypes.byref(native_machine)): - return None - return _PE_MACHINE_TO_NAME.get(native_machine.value) - - def _windows_user_runnable_pe_machines() -> Optional[set]: """PE machines this host runs in user mode via GetMachineTypeAttributes (the only API reporting AMD64-on-ARM64 emulation); None when unavailable (pre-Win11 22000) so callers fall back.""" @@ -337,31 +311,12 @@ def _windows_user_runnable_pe_machines() -> Optional[set]: def _windows_native_machine() -> str: - """The Windows host's NATIVE machine, upper-cased: ``IsWow64Process2`` (the only API that tells - the truth from an emulated x64 process on ARM64), then ``PROCESSOR_ARCHITEW6432`` / - ``PROCESSOR_ARCHITECTURE``, then ``platform.machine()`` (which lies under emulation). - ``GetNativeSystemInfo`` is NOT used: it also returns emulated details. - - ``platform.machine()`` reports the PROCESS architecture, which lies under emulation: the desktop update - chain runs an x64 hermes-setup.exe (and thus x64 Python) on Windows-on-ARM devices, where - ``platform.machine()`` returns ``AMD64`` even though the OS is ARM64. The #71119 integrity gate then - rejected the CORRECT ARM64 rebuild as an "architecture mismatch" (#69179 follow-up report). Probe order: - 1. ``IsWow64Process2`` with a correctly-typed current-process HANDLE (#71218 + HANDLE-truncation fix). - 2. 3. - """ + """Return the native Windows machine name in upper-case PE vocabulary.""" if sys.platform == "win32": - try: - name = _windows_native_machine_from_iswow64() - except (OSError, AttributeError, TypeError, ValueError): - name = None # API missing, DLL load failure in tests, mistyped binding - if name: - return name - env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE") - if env_arch: - return env_arch.upper() - import platform as _platform - - return (_platform.machine() or "").upper() + return {"arm64": "ARM64", "amd64": "AMD64", "x86": "X86"}.get( + facts.native_arch(), (platform.machine() or "").upper() + ) + return (platform.machine() or "").upper() def _expected_windows_pe_machines() -> set: diff --git a/hermes_constants.py b/hermes_constants.py index 7eb4c52dfa..79ed5e16ec 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -649,8 +649,9 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: """ import time - arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower() - node_arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "x86": "x86"}.get(arch) + from hermes_platform.host import facts + + node_arch = {"amd64": "x64", "arm64": "arm64", "x86": "x86"}.get(facts.native_arch()) if node_arch is None: return False home = home or get_hermes_home() diff --git a/tests/hermes_cli/test_desktop_exe_integrity.py b/tests/hermes_cli/test_desktop_exe_integrity.py index 55e0e9f8d7..efa5109163 100644 --- a/tests/hermes_cli/test_desktop_exe_integrity.py +++ b/tests/hermes_cli/test_desktop_exe_integrity.py @@ -25,12 +25,20 @@ import pytest from hermes_cli import main as cli_main from hermes_cli import main_desktop +from hermes_platform.host import facts PE_AMD64 = 0x8664 PE_ARM64 = 0xAA64 PE_I386 = 0x014C +@pytest.fixture(autouse=True) +def _clear_host_fact_caches(): + facts.clear_caches() + yield + facts.clear_caches() + + def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = None) -> Path: """Write a minimal, structurally-complete PE file with one section. From 0f9007178da1b48e24973b64634c92a2f5bbf6a6 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:18:08 +0530 Subject: [PATCH 09/35] test: known-path ratchet walks nested join() arguments; allowlist moves to tests/fixtures Known-path tables build rows with nested os.path.join() calls, which the first scanner missed. Walking nested arguments covers those tables. The allowlist lives in tests/fixtures/ because tests/data/ is gitignored. --- .../resolution_allowlist.json | 25 +++++++++++++++---- tests/test_managed_runtime_resolution.py | 17 +++++-------- 2 files changed, 26 insertions(+), 16 deletions(-) rename tests/{data => fixtures}/resolution_allowlist.json (97%) diff --git a/tests/data/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json similarity index 97% rename from tests/data/resolution_allowlist.json rename to tests/fixtures/resolution_allowlist.json index 97c65c9a90..72514f9e39 100644 --- a/tests/data/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -1,9 +1,4 @@ [ - { - "path": "agent/anthropic_adapter.py", - "symbol": "", - "kind": "known_path_table" - }, { "path": "agent/anthropic_adapter.py", "symbol": "_claude_code_candidates", @@ -703,5 +698,25 @@ "path": "tui_gateway/methods_prompt.py", "symbol": "_", "kind": "bare_which" + }, + { + "path": "agent/anthropic_adapter.py", + "symbol": "", + "kind": "known_path_table" + }, + { + "path": "hermes_cli/_early_recovery.py", + "symbol": "_find_uv_binary", + "kind": "known_path_table" + }, + { + "path": "hermes_cli/copilot_auth.py", + "symbol": "_gh_cli_candidates", + "kind": "known_path_table" + }, + { + "path": "tools/computer_use/cua_backend_driver.py", + "symbol": "_candidate_cua_driver_commands", + "kind": "known_path_table" } ] diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index 86d4455317..9440da6691 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -35,7 +35,7 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[1] MODULE_MARKER = "" -_RESOLUTION_ALLOWLIST_PATH = REPO_ROOT / "tests/data/resolution_allowlist.json" +_RESOLUTION_ALLOWLIST_PATH = REPO_ROOT / "tests/fixtures/resolution_allowlist.json" _KNOWN_PATH_FRAGMENTS = ( ".local/bin", ".cargo/bin", @@ -137,8 +137,6 @@ def _iter_which_calls(tree: ast.AST): class _ResolutionSiteVisitor(ast.NodeVisitor): - """Collect bare PATH lookups and known-path tables by enclosing symbol.""" - def __init__(self, tree: ast.Module) -> None: self._scope: list[tuple[str, bool]] = [] self._shutil_aliases = {"shutil"} @@ -204,16 +202,13 @@ class _ResolutionSiteVisitor(ast.NodeVisitor): self.generic_visit(node) def _visit_path_table(self, node: ast.List | ast.Tuple) -> None: + # Re-join fragments split across path-construction arguments before matching. strings = [ - element.value - for element in node.elts - if isinstance(element, ast.Constant) and isinstance(element.value, str) + child.value for child in ast.walk(node) + if isinstance(child, ast.Constant) and isinstance(child.value, str) ] - fragments = { - fragment - for fragment in _KNOWN_PATH_FRAGMENTS - if any(fragment in value for value in strings) - } + joined = "/".join(strings) + fragments = {fragment for fragment in _KNOWN_PATH_FRAGMENTS if fragment in joined} if len(fragments) >= 2: self.sites.add((self._symbol, "known_path_table")) From a26da049ac6e193086d3c1c7d0fc758977526245 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 20 Sep 2026 19:20:58 +0530 Subject: [PATCH 10/35] test: managed-Node heal tests pin native_arch where production reads it The production reader ignores PROCESSOR_ARCHITECTURE by design. Patching native_arch() makes these tests independent of the host running them. --- tests/test_hermes_constants.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index ae2e813f60..02daf50301 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -9,6 +9,7 @@ import pytest import hermes_constants from hermes_platform.host import runtime as host_runtime +from hermes_platform.host import facts as host_facts from hermes_constants import ( VALID_REASONING_EFFORTS, agent_browser_runnable, @@ -971,7 +972,8 @@ class TestWindowsHealStageSwap: import urllib.request monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - monkeypatch.setenv("PROCESSOR_ARCHITECTURE", "AMD64") + # Pin the native architecture to the x64 archive served by the fake index. + monkeypatch.setattr(host_facts, "native_arch", lambda: "amd64") monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv( "HERMES_NODE_TARGET_MAJOR", From a22415950ac407786e8e8c4b661bd65355c4e3b4 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 21 Sep 2026 09:52:41 +0530 Subject: [PATCH 11/35] test: resolution allowlist follows main (webhook_filters, gateway_service_unit, git_credentials, mcp PATHEXT) Main moved or removed four allowlisted sites and added three. Updating the rows preserves the ratchet's stale-entry check. --- tests/fixtures/resolution_allowlist.json | 27 ++++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/fixtures/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json index 72514f9e39..bab4727c23 100644 --- a/tests/fixtures/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -89,11 +89,6 @@ "symbol": "_remux_aac_to_m4a", "kind": "bare_which" }, - { - "path": "gateway/platforms/webhook_filters.py", - "symbol": "WebhookRouteProcessor.run_route_script", - "kind": "bare_which" - }, { "path": "gateway/platforms/whatsapp_cloud.py", "symbol": "", @@ -134,6 +129,11 @@ "symbol": "_external_process_spec", "kind": "bare_which" }, + { + "path": "hermes_cli/cli_session_mixin.py", + "symbol": "CLISessionMixin._clear_terminal_on_exit", + "kind": "bare_which" + }, { "path": "hermes_cli/commands_completion.py", "symbol": "SlashCommandCompleter._get_project_files", @@ -179,11 +179,6 @@ "symbol": "_safe_which", "kind": "bare_which" }, - { - "path": "hermes_cli/gateway.py", - "symbol": "_append_node_dir_for_service", - "kind": "bare_which" - }, { "path": "hermes_cli/gateway.py", "symbol": "_build_wsl_interop_paths", @@ -229,6 +224,11 @@ "symbol": "supports_systemd_services", "kind": "bare_which" }, + { + "path": "hermes_cli/gateway_service_unit.py", + "symbol": "_append_node_dir_for_service", + "kind": "bare_which" + }, { "path": "hermes_cli/gateway_windows.py", "symbol": "_exec_schtasks", @@ -241,7 +241,7 @@ }, { "path": "hermes_cli/git_credentials.py", - "symbol": "_github_token", + "symbol": "_gh_cli_token", "kind": "bare_which" }, { @@ -569,11 +569,6 @@ "symbol": "_resolve_stdio_command", "kind": "bare_which" }, - { - "path": "tools/mcp_tool_config.py", - "symbol": "_which_with_config_pathext", - "kind": "bare_which" - }, { "path": "tools/process_registry.py", "symbol": "_build_systemd_scope_argv", From 2b49a6fb4395c09175a8bb549159d74a83154f25 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 21 Sep 2026 09:56:26 +0530 Subject: [PATCH 12/35] docs(agents): machine facts and resource lookup go through hermes_platform Document the shared source for control-host facts and the resolution ratchet so new callers do not create competing implementations. --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1a9edd8af8..c704bb8c9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -286,6 +286,16 @@ families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15 spawns (`served_profile_child_env`, never `os.environ.copy()`). Fail-closed reads exist only after `set_multiplex_active(True)`. Prove live with two homes (A→B→A) under multiplex, not one temp `HERMES_HOME`. Advisory lint: `scripts/check_profile_scope_patterns.py`. +- **Machine facts and resource lookup go through `hermes_platform`.** `hermes_platform.host` is the + one answer for OS family, native architecture (`IsWow64Process2` → `platform.machine()`; never + `PROCESSOR_ARCHITECTURE` alone, it reads AMD64 under x64-on-ARM64 emulation), CPU identity, and + WSL/container/Termux. Facts are cached per process and take **no environment-variable input**, so + a hardware recognizer (`host/products.py`) cannot be set from a shell. Distinguish the control + host (where this Python runs) from the terminal execution target (SSH/container) and the Desktop + client (another machine): `host.*` answers only the first. A new bare `shutil.which` or a + hand-written known-path table outside `hermes_platform/` fails + `tests/test_managed_runtime_resolution.py` unless allowlisted with a reason; resolvers land in + `hermes_platform/resolver/`. Lookup never installs, downloads, or starts anything. - **Argparse alias dispatch:** `add_parser("list", aliases=["ls"])` sets `dest` to the literal the user typed (`"ls"`). Dispatch must accept both (caught PTY-testing `hermes webhook ls`). - **Don't wire in dead code without E2E validation.** Unshipped code was dead for a reason; From 6fc56d700b31414ee5e99837787368ceb953a7f8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 21 Sep 2026 09:39:30 -0400 Subject: [PATCH 13/35] test: allowlist follows main's gateway wizard split (_setup_signal moved to gateway_setup_wizard.py) main moved _setup_signal out of hermes_cli/gateway.py after this branch was cut; the ratchet's stale-row test fails on the merge result without this rename. --- tests/fixtures/resolution_allowlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json index bab4727c23..0403a012df 100644 --- a/tests/fixtures/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -200,7 +200,7 @@ "kind": "bare_which" }, { - "path": "hermes_cli/gateway.py", + "path": "hermes_cli/gateway_setup_wizard.py", "symbol": "_setup_signal", "kind": "bare_which" }, From 8027c370a107a12bdb442412a2ab1a1e4cdd1685 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 22 Sep 2026 16:56:36 +1000 Subject: [PATCH 14/35] fix(desktop): kanban (and every connection-scoped query) follows the gateway switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connection switch left the kanban pane painting the previous gateway's boards. Three holes, one root cause — the switch commit runs its cache wipe BEFORE the activation publishes the new request scope, and nothing re-invalidates after the tags move: - store/connections: the CONNECTION twin of profile.ts's $activeGatewayProfile subscription. beginGatewaySwitch → invalidateProfileScopedQueries fires inside beforeActivate, so its refetches ride the OUTGOING backend; when the connection id moves (profile unchanged, e.g. default→default) nothing re-invalidates. Ride the existing $activeConnectionProfile.subscribe (no new computed listener) and invalidate on the actual id change; an undefined sentinel suppresses the first fire because the id is legitimately null on the local primary. - plugins/kanban/api: every query key embeds the active connection scope (kanbanConnectionScope — the hermes-bots roster pattern), so a switch is a clean cache miss and one gateway's boards can never paint under another connection's route. The persisted board slug becomes per-connection storage (boardSlug.): a slug that pins a localhost board 404s on the next gateway and froze the pane on the last successful (localhost) data. - plugins/kanban/api: the events socket re-opens on a connection change — pluginSocket resolves the backend only at connect time, so a socket left open across a switch streamed the old gateway's events against the new connection's cache forever. Regression tests in src/store/kanban-connection-scope.test.ts drive the REAL selectConnection chain: the post-switch refetch must be tagged for the NEW gateway, and a no-change registry republication must not refetch. Mutation-checked: both fail without the twin. (cherry picked from commit 3ea28be090c3ee885b296694c74be40f84081d2c) --- apps/desktop/src/plugins/kanban/api.ts | 64 ++++++- apps/desktop/src/plugins/kanban/board.tsx | 5 +- apps/desktop/src/plugins/kanban/drawer.tsx | 2 +- apps/desktop/src/store/connections.ts | 36 +++- .../src/store/kanban-connection-scope.test.ts | 175 ++++++++++++++++++ 5 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/store/kanban-connection-scope.test.ts diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index d079693ace..7856026121 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -7,10 +7,17 @@ * (the app's standard, via the SDK). This module owns the query keys, the REST * calls, and the selected-board atom — every call passes `?board=` so the * desktop's selection never flips the server-wide current-board pointer. + * + * Every query key and the persisted board selection are scoped by the ACTIVE + * CONNECTION (`host.state.connectionId`): a board lives on ONE gateway, so a + * connection switch must be a clean cache miss (the hermes-bots roster + * pattern), and each gateway remembers its own selected board instead of + * pinning a slug the next gateway 404s on. */ import { atom, + host, type PluginOs, type PluginRestOptions, type PluginStorage, @@ -60,6 +67,12 @@ const INTRO_KEY = 'introDismissed' const LANES_KEY = 'lanesByProfile' const COLLAPSED_KEY = 'collapsedLanes' +/** Cache-scope id for the active connection — the segment every query key + * embeds. `'local'` (never '') for the local pool so the key is never empty. */ +export function kanbanConnectionScope(): string { + return String(host.state.connectionId?.get?.() || 'local') +} + /** One live `task_events` frame → precise cache invalidation: the board, plus * each touched task's detail. The polls (8s board / 4s drawer) stay as the * fallback — the socket just makes the board feel instant. */ @@ -94,7 +107,14 @@ interface Persisted { /** Bind the plugin's doors at register time and return a disposer the host * runs on unload/disable — so nothing (store sync, socket) survives a toggle * or duplicates on re-enable. The events socket is pinned to a board at - * handshake, so a board switch closes + reopens it. */ + * handshake, so a board switch closes + reopens it. + * + * The events socket is ALSO re-opened when the active CONNECTION changes: + * `pluginSocket` resolves the backend only at connect time, so a socket left + * open across a switch keeps streaming the previous gateway's events against + * the new connection's request scope. The board slug re-hydrates from the + * per-connection storage key for the same reason — one gateway's slug pins a + * board the next gateway 404s on. */ export function bindApi( r: Rest, storage: PluginStorage, @@ -112,7 +132,6 @@ export function bindApi( unsubs.push(atom.listen(value => storage.set(key, value))) } - persist($boardSlug, BOARD_SLUG_KEY, '') persist($introDismissed, INTRO_KEY, false) persist($lanesByProfile, LANES_KEY, false) persist($collapsedLanes, COLLAPSED_KEY, {}) @@ -124,6 +143,26 @@ export function bindApi( close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data)) } + // Board selection is per-connection: hydrate under the connection's storage + // key, and re-hydrate + reopen the socket when the connection changes (the + // outgoing gateway's slug may 404 on the next one). + const slugStorageKey = () => `${BOARD_SLUG_KEY}.${kanbanConnectionScope()}` + + const hydrateSlug = () => { + $boardSlug.set(storage.get(slugStorageKey(), '')) + } + + hydrateSlug() + unsubs.push($boardSlug.listen(slug => storage.set(slugStorageKey(), slug))) + unsubs.push( + host.state.connectionId.listen(() => { + // Query keys embed the connection scope (kanbanConnectionScope), so the + // new connection is already a clean React Query cache miss — no + // invalidation needed here. Only the LIVE bindings (socket, slug) follow. + hydrateSlug() + open($boardSlug.get()) + }) + ) open($boardSlug.get()) unsubs.push($boardSlug.listen(open)) @@ -157,15 +196,20 @@ function withBoard(path: string, params: Record = {}): string { return qs ? `${path}?${qs}` : path } -// ── query keys (all board-scoped so switching boards is a clean cache miss) ── +// ── query keys (connection- AND board-scoped so switching either is a clean +// cache miss — one gateway's boards/tasks must never paint under another +// connection's route) ───────────────────────────────────────────────────────── -export const boardKey = (slug: string, archived: boolean) => ['kanban', 'board', slug, archived] as const -export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id] as const -export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const -export const BOARDS_KEY = ['kanban', 'boards'] as const -export const PROFILES_KEY = ['kanban', 'profiles'] as const -export const PROJECTS_KEY = ['kanban', 'projects'] as const -export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const +const scopeKey = (): readonly [string] => [kanbanConnectionScope()] + +export const boardKey = (slug: string, archived: boolean) => + ['kanban', 'board', ...scopeKey(), slug, archived] as const +export const taskKey = (slug: string, id: string) => ['kanban', 'task', ...scopeKey(), slug, id] as const +export const logKey = (slug: string, id: string) => ['kanban', 'log', ...scopeKey(), slug, id] as const +export const BOARDS_KEY = ['kanban', 'boards', ...scopeKey()] as const +export const PROFILES_KEY = ['kanban', 'profiles', ...scopeKey()] as const +export const PROJECTS_KEY = ['kanban', 'projects', ...scopeKey()] as const +export const ORCHESTRATION_KEY = ['kanban', 'orchestration', ...scopeKey()] as const // ── reads ───────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index a7a8dcd14a..89087ca59f 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -76,7 +76,8 @@ import { fetchBoards, fetchProfiles, patchTask, - PROFILES_KEY + PROFILES_KEY, + taskKey } from './api' import { BoardSwitcher } from './board-switcher' import { TaskDrawer } from './drawer' @@ -1207,7 +1208,7 @@ export function KanbanBoardPage() { }, onSettled: (_data, _err, vars) => { void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) - void qc.invalidateQueries({ queryKey: ['kanban', 'task', slug, vars.id] }) + void qc.invalidateQueries({ queryKey: taskKey(slug, vars.id) }) } }) diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx index 525df44547..bdca9b43ff 100644 --- a/apps/desktop/src/plugins/kanban/drawer.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.tsx @@ -586,7 +586,7 @@ export function TaskDrawer({ const invalidate = () => { void qc.invalidateQueries({ queryKey: taskKey(slug, id!) }) - void qc.invalidateQueries({ queryKey: ['kanban', 'board', slug] }) + void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) } // Optimistic status change against the task cache; rolls back + toasts on a diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 3801e139b4..1cda5c1d34 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores' import { getProfiles } from '@/api/profiles' import type { DesktopConnectionsRegistry } from '@/global' +import { invalidateProfileScopedQueries } from '@/lib/query-client' import { persistStringRecord, storedStringRecord } from '@/lib/storage' import { BACKEND_BOOT_WAIT_TIMEOUT_MS, isTimeoutError, withTimeout } from '@/lib/with-timeout' import { $connectionsRegistry } from '@/store/connection-registry-state' @@ -74,10 +75,39 @@ const $activeConnectionProfile = computed( }) ) -// Remember one profile per source, so switching machines is a re-home rather -// than a reset to `default`. The map is local UI preference only; Electron -// remains the authority for the connection registry and all secrets. +// The CONNECTION twin of profile.ts's $activeGatewayProfile subscription. +// The switch commit point (beginGatewaySwitch → invalidateProfileScopedQueries) +// runs inside beforeActivate — BEFORE the activation publishes the new request +// scope (applyActive → setApiRequestConnection) — so that invalidation's +// immediate refetches ride the OUTGOING backend and repaint its data under the +// incoming connection's route. When the connection id later moves, no kanban +// query key changes (the pane's keys are unscoped), so nothing re-invalidates: +// the pane keeps painting the previous gateway's boards. Invalidate here, on +// the actual (connectionId) change, so the refetch lands on the backend the +// tags now name. +// +// The profile twin suppresses its FIRST fire via `_lastRoutedProfile !== +// null`, which relies on the profile atom being non-null at startup. The +// connection id is legitimately null on the primary local route, so null +// cannot double as "uninitialized" here — an explicit sentinel does. +let _lastRoutedConnectionId: null | string | undefined + +/** Remember one profile per source, so switching machines is a re-home rather + * than a reset to `default`. The map is local UI preference only; Electron + * remains the authority for the connection registry and all secrets. + */ $activeConnectionProfile.subscribe(({ connectionId, descriptorProfile, profile, registryScoped }) => { + // The connection twin (above) rides this existing subscription — the one + // listener already holding the computed open — instead of a second + // subscribe, so the store's task graph is unchanged. + const id = connectionId ?? null + + if (_lastRoutedConnectionId !== undefined && _lastRoutedConnectionId !== id) { + invalidateProfileScopedQueries() + } + + _lastRoutedConnectionId = id + // A migrated v1 per-profile remote may expose a client-side alias such as // "work" while the registered source's actual profile is "default". Only // remember a source/profile pair after Electron confirms that exact v2 diff --git a/apps/desktop/src/store/kanban-connection-scope.test.ts b/apps/desktop/src/store/kanban-connection-scope.test.ts new file mode 100644 index 0000000000..aaaab9b592 --- /dev/null +++ b/apps/desktop/src/store/kanban-connection-scope.test.ts @@ -0,0 +1,175 @@ +import { QueryObserver } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesConnection } from '@/global' + +// A connection switch must leave every profile-scoped query refetched against +// the NEW gateway. The switch commit point (beginGatewaySwitch → +// wipeSessionListsForGatewaySwitch → invalidateProfileScopedQueries) runs +// inside beforeActivate — BEFORE the activation publishes the new request +// scope (applyActive → setApiRequestConnection) — so that invalidation's +// refetches ride the OUTGOING backend. When the connection id later moves and +// the profile atom is unchanged, nothing re-invalidates: a kanban pane (or +// any connection-scoped query) keeps painting the previous gateway's data. +// store/connections closes the hole with the CONNECTION twin of profile.ts's +// $activeGatewayProfile subscription: invalidate on the actual connection-id +// change, so the refetch lands on the backend the tags now name. +// +// Real store chain (store/gateway + store/profile + store/connections), only +// the HermesGateway socket class stubbed — same harness as +// plugin-socket-scope.test.ts. + +vi.mock('@/hermes', async importOriginal => { + const actual = await importOriginal>() + + return { + ...actual, + // Stub only the socket class so gateway activations don't dial real WS. + HermesGateway: class { + connectionState = 'closed' + connect = async (_wsUrl: string): Promise => { + this.connectionState = 'open' + } + close = (): void => { + this.connectionState = 'closed' + } + onEvent = vi.fn(() => () => {}) + onState = vi.fn(() => () => {}) + } + } +}) +vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) + +const { getApiRequestConnection, setApiRequestConnection, setApiRequestProfile } = await import('@/api/client') +const { queryClient } = await import('@/lib/query-client') +const { closeSecondaryGateways, configureGatewayRegistry, setPrimaryGateway } = await import('@/store/gateway') +const { $activeGatewayProfile } = await import('@/store/profile') +const { selectConnection, setConnectionsRegistry, _resetConnectionsForTests } = await import('@/store/connections') + +const conn = (over: Partial = {}): HermesConnection => + ({ + authMode: 'oauth', + baseUrl: 'https://pool.invalid', + mode: 'remote', + token: 'fake-test-token', + wsUrl: 'wss://pool.invalid/api/ws?token=fake-test-token', + ...over + }) as HermesConnection + +const registry = { + connections: [ + { id: 'local', kind: 'local', label: 'This device', tokenPreview: null, tokenSet: false }, + { id: 'spark', kind: 'remote', label: 'Spark', tokenPreview: '...abc', tokenSet: true } + ], + primary: 'local', + secureTokenStorage: true, + version: 2 +} as never + +describe('connection-switch query invalidation', () => { + let tagsAtFetch: Array + + let observer: QueryObserver | undefined + + beforeEach(() => { + vi.stubGlobal('window', { + hermesDesktop: { + api: vi.fn(async () => ({})), + connections: { + list: vi.fn(async () => registry), + setLastUsed: vi.fn(async () => ({ ok: true, registry })) + }, + getConnection: vi.fn(async (profile?: null | string) => + conn({ baseUrl: 'http://127.0.0.1:8117', profile: profile ?? 'default' }) + ), + getConnectionFor: vi.fn(async ({ connectionId }: { connectionId?: null | string }) => + conn({ + baseUrl: connectionId === 'spark' ? 'https://spark.invalid' : 'http://127.0.0.1:8117', + connectionId: connectionId ?? undefined, + profile: 'default', + registryScoped: true + }) + ), + getGatewayWsUrl: vi.fn(async () => 'wss://pool.invalid/api/ws?ticket=fake'), + getGatewayWsUrlFor: vi.fn(async () => 'wss://spark.invalid/api/ws?ticket=fake'), + touchBackend: vi.fn(async () => ({ ok: true })) + }, + localStorage: new Map() as unknown as Storage + }) + configureGatewayRegistry({ onEvent: vi.fn() }) + setPrimaryGateway({ connectionState: 'open' } as never, 'default') + setConnectionsRegistry(registry) + queryClient.clear() + tagsAtFetch = [] + }) + + afterEach(() => { + observer?.destroy() + queryClient.clear() + closeSecondaryGateways() + $activeGatewayProfile.set('default') + setApiRequestProfile(null) + setApiRequestConnection(null) + _resetConnectionsForTests() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('refetches connection-scoped queries against the NEW gateway after a switch', async () => { + // One active profile-scoped query, exactly like the kanban pane's boards + // list: the connection tag is read at queryFn time, which is what + // pluginRest → hermesApi does. + observer = new QueryObserver(queryClient, { + queryKey: ['kanban', 'boards'], + queryFn: async () => { + tagsAtFetch.push(getApiRequestConnection()) + + return { boards: [] } + }, + staleTime: 30_000 + }) + const unsubscribe = observer.subscribe(() => undefined) + + // Let the initial fetch (old backend) settle. + await vi.waitFor(() => expect(tagsAtFetch).toEqual([null])) + tagsAtFetch.length = 0 + + await selectConnection('spark', { profile: 'default' }) + + // The switch's own pre-commit wipe refetches early on the OLD tag; the + // connection twin must invalidate again once the tag names spark, so at + // least one fetch rides the new backend and the FINAL fetch is + // spark-tagged. + await vi.waitFor(() => expect(tagsAtFetch.at(-1)).toBe('spark'), { timeout: 2_000 }) + + expect(getApiRequestConnection()).toBe('spark') + expect(tagsAtFetch).toContain('spark') + + unsubscribe() + }) + + it('does not refetch when the connection id has not actually changed', async () => { + let fetches = 0 + observer = new QueryObserver(queryClient, { + queryKey: ['kanban', 'boards'], + queryFn: async () => { + fetches += 1 + + return { boards: [] } + }, + staleTime: 30_000 + }) + const unsubscribe = observer.subscribe(() => undefined) + + await vi.waitFor(() => expect(fetches).toBe(1)) + + // Re-publishing the same registry must not re-invalidate (first-fire + // suppression + change guard, same contract as the profile twin). + setConnectionsRegistry(registry) + + await new Promise(resolve => setTimeout(resolve, 200)) + expect(fetches).toBe(1) + + unsubscribe() + }) +}) From a6251894104f76493ecd98b37a6e5ea6ba5eaf0a Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:10:08 +0530 Subject: [PATCH 15/35] fix(desktop): kanban query keys read the connection scope reactively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list keys (BOARDS_KEY, PROFILES_KEY, PROJECTS_KEY, ORCHESTRATION_KEY) were module-level consts, so their scope segment was evaluated once at import and stayed 'local' for the life of the app — the boards list, the query behind the reported symptom, never got the cache miss the scoping promised. The per-slug builders read host.state.connectionId.get() during render with no subscription, so when the slug was the same on both gateways (the default '' is) the observer kept the old-scoped key, the twin's refetch stored the new gateway's data under the old scope, and the next re-render flipped the key into a second fetch. Every builder now takes the scope explicitly: rendering components get it from useKanbanScope() (useValue on the SDK atom, so a switch re-renders them and their observers move to the new key), non-rendering actions (mutation settles, socket frames) from kanbanConnectionScope() at call time. boardKeyPrefix(scope) replaces the raw ['kanban', 'board'] arrays that leaked the key shape into six call sites. --- apps/desktop/src/plugins/kanban/api.ts | 42 ++++++++++------- .../src/plugins/kanban/board-switcher.tsx | 20 +++++---- apps/desktop/src/plugins/kanban/board.tsx | 45 ++++++++++--------- .../src/plugins/kanban/drawer.test.tsx | 4 +- apps/desktop/src/plugins/kanban/drawer.tsx | 26 ++++++----- .../src/plugins/kanban/orchestration.tsx | 17 ++++--- apps/desktop/src/plugins/kanban/plugin.tsx | 5 ++- apps/desktop/src/plugins/kanban/ui.tsx | 6 ++- 8 files changed, 97 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index 7856026121..8135c0fde3 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -22,7 +22,8 @@ import { type PluginRestOptions, type PluginStorage, type PluginTranslate, - queryClient + queryClient, + useValue } from '@hermes/plugin-sdk' // Native completion notification. @@ -68,9 +69,16 @@ const LANES_KEY = 'lanesByProfile' const COLLAPSED_KEY = 'collapsedLanes' /** Cache-scope id for the active connection — the segment every query key - * embeds. `'local'` (never '') for the local pool so the key is never empty. */ + * embeds. `'local'` covers the pre-descriptor null; the SDK atom already + * reports 'local' for the local pool. For NON-rendering code (mutations, + * socket frames); rendering components use `useKanbanScope` so the keys they + * build during render recompute when the connection changes. */ export function kanbanConnectionScope(): string { - return String(host.state.connectionId?.get?.() || 'local') + return host.state.connectionId.get() ?? 'local' +} + +export function useKanbanScope(): string { + return useValue(host.state.connectionId) ?? 'local' } /** One live `task_events` frame → precise cache invalidation: the board, plus @@ -83,12 +91,13 @@ function onEventsFrame(slug: string, data: unknown): void { return } - void queryClient.invalidateQueries({ queryKey: ['kanban', 'board'] }) + const scope = kanbanConnectionScope() + void queryClient.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) // Any event can change a board's card count — keep the switcher badge honest. - void queryClient.invalidateQueries({ queryKey: BOARDS_KEY }) + void queryClient.invalidateQueries({ queryKey: boardsKey(scope) }) for (const taskId of new Set(events.map(event => event.task_id).filter(Boolean))) { - void queryClient.invalidateQueries({ queryKey: taskKey(slug, taskId!) }) + void queryClient.invalidateQueries({ queryKey: taskKey(scope, slug, taskId!) }) } // Completion notification (after invalidation so notify failure @@ -200,16 +209,17 @@ function withBoard(path: string, params: Record = {}): string { // cache miss — one gateway's boards/tasks must never paint under another // connection's route) ───────────────────────────────────────────────────────── -const scopeKey = (): readonly [string] => [kanbanConnectionScope()] - -export const boardKey = (slug: string, archived: boolean) => - ['kanban', 'board', ...scopeKey(), slug, archived] as const -export const taskKey = (slug: string, id: string) => ['kanban', 'task', ...scopeKey(), slug, id] as const -export const logKey = (slug: string, id: string) => ['kanban', 'log', ...scopeKey(), slug, id] as const -export const BOARDS_KEY = ['kanban', 'boards', ...scopeKey()] as const -export const PROFILES_KEY = ['kanban', 'profiles', ...scopeKey()] as const -export const PROJECTS_KEY = ['kanban', 'projects', ...scopeKey()] as const -export const ORCHESTRATION_KEY = ['kanban', 'orchestration', ...scopeKey()] as const +/** Prefix matching every board query on one connection (all slugs, both + * archived views) — the mutation-settled invalidation target. */ +export const boardKeyPrefix = (scope: string) => ['kanban', 'board', scope] as const +export const boardKey = (scope: string, slug: string, archived: boolean) => + [...boardKeyPrefix(scope), slug, archived] as const +export const taskKey = (scope: string, slug: string, id: string) => ['kanban', 'task', scope, slug, id] as const +export const logKey = (scope: string, slug: string, id: string) => ['kanban', 'log', scope, slug, id] as const +export const boardsKey = (scope: string) => ['kanban', 'boards', scope] as const +export const profilesKey = (scope: string) => ['kanban', 'profiles', scope] as const +export const projectsKey = (scope: string) => ['kanban', 'projects', scope] as const +export const orchestrationKey = (scope: string) => ['kanban', 'orchestration', scope] as const // ── reads ───────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/plugins/kanban/board-switcher.tsx b/apps/desktop/src/plugins/kanban/board-switcher.tsx index ea01003d13..b08f937b88 100644 --- a/apps/desktop/src/plugins/kanban/board-switcher.tsx +++ b/apps/desktop/src/plugins/kanban/board-switcher.tsx @@ -36,14 +36,15 @@ import { type ReactNode, useEffect, useState } from 'react' import { $boardSlug, - BOARDS_KEY, + boardsKey, createBoard, deleteBoard, fetchBoards, fetchProjects, pluginOs, - PROJECTS_KEY, - updateBoard + projectsKey, + updateBoard, + useKanbanScope } from './api' import { runExportBoardFlow, runImportBoardFlow } from './transfer' import type { BoardMeta } from './types' @@ -58,7 +59,8 @@ const DEFAULT_BOARD = 'default' * deterministic branch. "No project" falls back to scratch sandboxes. */ function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; value: string }) { const k = useKanban() - const { data } = useQuery({ queryKey: PROJECTS_KEY, queryFn: fetchProjects, staleTime: 30_000 }) + const scope = useKanbanScope() + const { data } = useQuery({ queryKey: projectsKey(scope), queryFn: fetchProjects, staleTime: 30_000 }) const projects = data?.projects ?? [] return ( @@ -89,12 +91,13 @@ function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; va * the caller finish, or surface the error and leave the dialog open. */ function useBoardWrite(mutationFn: () => Promise, onDone: (result: T) => void) { const qc = useQueryClient() + const scope = useKanbanScope() return useMutation({ mutationFn, onError: err => host.notify({ kind: 'error', message: errText(err) }), onSuccess: result => { - void qc.invalidateQueries({ queryKey: BOARDS_KEY }) + void qc.invalidateQueries({ queryKey: boardsKey(scope) }) onDone(result) } }) @@ -283,8 +286,9 @@ export function BoardSwitcher() { // Delete reuses the app-wide label, the way sessions and profiles do. const { t } = useI18n() const qc = useQueryClient() + const scope = useKanbanScope() const slug = useValue($boardSlug) - const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 }) + const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: boardsKey(scope), staleTime: 30_000 }) const [adding, setAdding] = useState(false) const [settingsFor, setSettingsFor] = useState(null) const [renameFor, setRenameFor] = useState(null) @@ -296,7 +300,7 @@ export function BoardSwitcher() { const { result } = await deleteBoard(target.slug) $boardSlug.set('') - void qc.invalidateQueries({ queryKey: BOARDS_KEY }) + void qc.invalidateQueries({ queryKey: boardsKey(scope) }) host.notify({ kind: 'success', message: k.boardArchived(result.new_path) }) } @@ -321,7 +325,7 @@ export function BoardSwitcher() { if (imported) { $boardSlug.set(imported) - void qc.invalidateQueries({ queryKey: BOARDS_KEY }) + void qc.invalidateQueries({ queryKey: boardsKey(scope) }) } } diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index 89087ca59f..e1df48744f 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -67,7 +67,8 @@ import { $introDismissed, $lanesByProfile, boardKey, - BOARDS_KEY, + boardKeyPrefix, + boardsKey, bulkTasks, createTask, deleteTask, @@ -76,8 +77,9 @@ import { fetchBoards, fetchProfiles, patchTask, - PROFILES_KEY, - taskKey + profilesKey, + taskKey, + useKanbanScope } from './api' import { BoardSwitcher } from './board-switcher' import { TaskDrawer } from './drawer' @@ -547,7 +549,8 @@ function NewTaskDialog({ }) { const k = useKanban() const qc = useQueryClient() - const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 }) + const scope = useKanbanScope() + const { data: roster } = useQuery({ queryKey: profilesKey(scope), queryFn: fetchProfiles, staleTime: 60_000 }) // Title-only creates must RUN: "auto" resolves to the orchestration default // (ultimately the active profile), applied at create time. Never silently // unassigned — parking a card is the explicit choice, not the default. @@ -558,7 +561,7 @@ function NewTaskDialog({ // dir) unless the operator overrides it below. Set the board default in the // board switcher's "Board settings…". const selectedSlug = useValue($boardSlug) - const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 }) + const { data: boards } = useQuery({ queryKey: boardsKey(scope), queryFn: fetchBoards, staleTime: 30_000 }) const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current)) const boardDefaultKind = currentBoard?.default_workspace_kind || 'scratch' const boardDefaultDir = currentBoard?.default_workdir || '' @@ -658,7 +661,7 @@ function NewTaskDialog({ host.notify({ kind: 'warning', message: warning }) } - await qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) + await qc.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) onClose() } catch (err) { setError(errText(err)) @@ -966,10 +969,11 @@ function SelectionBar({ }) { const k = useKanban() const qc = useQueryClient() - const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 }) + const scope = useKanbanScope() + const { data: roster } = useQuery({ queryKey: profilesKey(scope), queryFn: fetchProfiles, staleTime: 60_000 }) const finish = (failed: Array<{ error?: string; id: string }>) => { - void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) + void qc.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) if (failed.length > 0) { host.notify({ @@ -1084,6 +1088,7 @@ function SelectionBar({ export function KanbanBoardPage() { const k = useKanban() const qc = useQueryClient() + const scope = useKanbanScope() const slug = useValue($boardSlug) const [archived, setArchived] = useState(false) @@ -1091,7 +1096,7 @@ export function KanbanBoardPage() { // slow heartbeat for socketless paths (OAuth remotes, dropped connections). const { data: board, error } = useQuery({ queryFn: () => fetchBoard(archived), - queryKey: boardKey(slug, archived), + queryKey: boardKey(scope, slug, archived), refetchInterval: 60_000 }) @@ -1190,48 +1195,48 @@ export function KanbanBoardPage() { const moveMut = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => patchTask(id, { status }), onMutate: async ({ id, status }) => { - await qc.cancelQueries({ queryKey: boardKey(slug, archived) }) - const previous = qc.getQueryData(boardKey(slug, archived)) + await qc.cancelQueries({ queryKey: boardKey(scope, slug, archived) }) + const previous = qc.getQueryData(boardKey(scope, slug, archived)) if (previous) { - qc.setQueryData(boardKey(slug, archived), moveCard(previous, id, status)) + qc.setQueryData(boardKey(scope, slug, archived), moveCard(previous, id, status)) } return { previous } }, onError: (err, _vars, context) => { if (context?.previous) { - qc.setQueryData(boardKey(slug, archived), context.previous) + qc.setQueryData(boardKey(scope, slug, archived), context.previous) } host.notify({ kind: 'error', message: errText(err) }) }, onSettled: (_data, _err, vars) => { - void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) - void qc.invalidateQueries({ queryKey: taskKey(slug, vars.id) }) + void qc.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) + void qc.invalidateQueries({ queryKey: taskKey(scope, slug, vars.id) }) } }) const deleteMut = useMutation({ mutationFn: (id: string) => deleteTask(id), onMutate: async id => { - await qc.cancelQueries({ queryKey: boardKey(slug, archived) }) - const previous = qc.getQueryData(boardKey(slug, archived)) + await qc.cancelQueries({ queryKey: boardKey(scope, slug, archived) }) + const previous = qc.getQueryData(boardKey(scope, slug, archived)) if (previous) { - qc.setQueryData(boardKey(slug, archived), removeCard(previous, id)) + qc.setQueryData(boardKey(scope, slug, archived), removeCard(previous, id)) } return { previous } }, onError: (err, _id, context) => { if (context?.previous) { - qc.setQueryData(boardKey(slug, archived), context.previous) + qc.setQueryData(boardKey(scope, slug, archived), context.previous) } host.notify({ kind: 'error', message: errText(err) }) }, - onSettled: () => void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) + onSettled: () => void qc.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) }) const onMove = (id: string, status: string) => { diff --git a/apps/desktop/src/plugins/kanban/drawer.test.tsx b/apps/desktop/src/plugins/kanban/drawer.test.tsx index 4e9f06afd5..bb7d5d5aa5 100644 --- a/apps/desktop/src/plugins/kanban/drawer.test.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // eslint-disable-next-line no-restricted-imports import { registerPluginLocales } from '@/i18n/plugin-i18n' -import { bindApi, taskKey } from './api' +import { bindApi } from './api' import { TaskDrawer } from './drawer' import { en, KANBAN_LOCALES } from './i18n' import type { KanbanTaskDetail } from './types' @@ -94,7 +94,7 @@ describe('task attachment compatibility', () => { // A later backend response restores the capability without remounting. detail = { ...legacyDetail, attachments: [] } - await act(() => client.invalidateQueries({ queryKey: taskKey('', legacyDetail.task.id) })) + await act(() => client.invalidateQueries({ queryKey: ['kanban', 'task'] })) expect(await screen.findByRole('button', { name: en.uploadAttachment })).toBeTruthy() expect(screen.getByText(en.noAttachments)).toBeTruthy() } diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx index bdca9b43ff..807e471a8e 100644 --- a/apps/desktop/src/plugins/kanban/drawer.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.tsx @@ -33,6 +33,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react' import { $boardSlug, addComment, + boardKeyPrefix, deleteTask, estimateTask, fetchLog, @@ -40,11 +41,12 @@ import { fetchTask, logKey, patchTask, - PROFILES_KEY, + profilesKey, reassignTask, reclaimTask, taskKey, - uploadAttachment + uploadAttachment, + useKanbanScope } from './api' import { ModelOverrideField, overridePatch } from './model-override' import { @@ -242,7 +244,8 @@ function AssigneeMenu({ onReassign: (p: string) => void }) { const k = useKanban() - const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 }) + const scope = useKanbanScope() + const { data: roster } = useQuery({ queryKey: profilesKey(scope), queryFn: fetchProfiles, staleTime: 60_000 }) return ( @@ -551,13 +554,14 @@ export function TaskDrawer({ }) { const k = useKanban() const qc = useQueryClient() + const scope = useKanbanScope() const slug = useValue($boardSlug) // Socket-invalidated (bindApi); the interval is only the socketless heartbeat. const { data: detail, error } = useQuery({ enabled: !!id, queryFn: () => fetchTask(id!), - queryKey: taskKey(slug, id ?? ''), + queryKey: taskKey(scope, slug, id ?? ''), refetchInterval: 30_000 }) @@ -568,7 +572,7 @@ export function TaskDrawer({ const { data: log } = useQuery({ enabled: !!id, queryFn: () => fetchLog(id!), - queryKey: logKey(slug, id ?? ''), + queryKey: logKey(scope, slug, id ?? ''), refetchInterval: running ? 3_000 : 15_000 }) @@ -585,8 +589,8 @@ export function TaskDrawer({ }, [id, onClose]) const invalidate = () => { - void qc.invalidateQueries({ queryKey: taskKey(slug, id!) }) - void qc.invalidateQueries({ queryKey: ['kanban', 'board'] }) + void qc.invalidateQueries({ queryKey: taskKey(scope, slug, id!) }) + void qc.invalidateQueries({ queryKey: boardKeyPrefix(scope) }) } // Optimistic status change against the task cache; rolls back + toasts on a @@ -594,18 +598,18 @@ export function TaskDrawer({ const moveMut = useMutation({ mutationFn: (status: string) => patchTask(id!, { status }), onMutate: async status => { - await qc.cancelQueries({ queryKey: taskKey(slug, id!) }) - const previous = qc.getQueryData(taskKey(slug, id!)) + await qc.cancelQueries({ queryKey: taskKey(scope, slug, id!) }) + const previous = qc.getQueryData(taskKey(scope, slug, id!)) if (previous) { - qc.setQueryData(taskKey(slug, id!), { ...previous, task: { ...previous.task, status } }) + qc.setQueryData(taskKey(scope, slug, id!), { ...previous, task: { ...previous.task, status } }) } return { previous } }, onError: (err, _status, context) => { if (context?.previous) { - qc.setQueryData(taskKey(slug, id!), context.previous) + qc.setQueryData(taskKey(scope, slug, id!), context.previous) } host.notify({ kind: 'error', message: errText(err) }) diff --git a/apps/desktop/src/plugins/kanban/orchestration.tsx b/apps/desktop/src/plugins/kanban/orchestration.tsx index 623c7b166c..255ba6e750 100644 --- a/apps/desktop/src/plugins/kanban/orchestration.tsx +++ b/apps/desktop/src/plugins/kanban/orchestration.tsx @@ -25,10 +25,11 @@ import { autoDescribeProfile, fetchOrchestration, fetchProfiles, - ORCHESTRATION_KEY, - PROFILES_KEY, + orchestrationKey, + profilesKey, saveOrchestration, - saveProfileDescription + saveProfileDescription, + useKanbanScope } from './api' import type { KanbanProfile } from './types' import { errText, FIELD_LABEL, useKanban } from './ui' @@ -71,8 +72,9 @@ function ProfilePicker({ function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) { const k = useKanban() const qc = useQueryClient() + const scope = useKanbanScope() const [draft, setDraft] = useState(profile.description) - const invalidate = () => void qc.invalidateQueries({ queryKey: PROFILES_KEY }) + const invalidate = () => void qc.invalidateQueries({ queryKey: profilesKey(scope) }) const save = useMutation({ mutationFn: () => saveProfileDescription(profile.name, draft.trim()), @@ -132,13 +134,14 @@ function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) { export function OrchestrationPanel() { const k = useKanban() const qc = useQueryClient() - const { data: settings } = useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration }) - const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 }) + const scope = useKanbanScope() + const { data: settings } = useQuery({ queryKey: orchestrationKey(scope), queryFn: fetchOrchestration }) + const { data: roster } = useQuery({ queryKey: profilesKey(scope), queryFn: fetchProfiles, staleTime: 60_000 }) const save = useMutation({ mutationFn: (patch: Record) => saveOrchestration(patch), onError: err => host.notify({ kind: 'error', message: errText(err) }), - onSuccess: () => void qc.invalidateQueries({ queryKey: ORCHESTRATION_KEY }) + onSuccess: () => void qc.invalidateQueries({ queryKey: orchestrationKey(scope) }) }) if (!settings || !roster) { diff --git a/apps/desktop/src/plugins/kanban/plugin.tsx b/apps/desktop/src/plugins/kanban/plugin.tsx index bab22025df..e819eaa5d6 100644 --- a/apps/desktop/src/plugins/kanban/plugin.tsx +++ b/apps/desktop/src/plugins/kanban/plugin.tsx @@ -30,7 +30,7 @@ import { useValue } from '@hermes/plugin-sdk' -import { $boardSlug, bindApi, boardKey, fetchBoard } from './api' +import { $boardSlug, bindApi, boardKey, fetchBoard, useKanbanScope } from './api' import { KanbanBoardPage } from './board' import { KANBAN_LOCALES } from './i18n' import { $newTaskLane, useKanban } from './ui' @@ -40,12 +40,13 @@ import { $newTaskLane, useKanban } from './ui' // the page); hidden when nothing is in flight (or unloaded). function KanbanCount() { const k = useKanban() + const scope = useKanbanScope() const slug = useValue($boardSlug) // Socket-invalidated like the page (same cache); slow socketless heartbeat. const { data: board } = useQuery({ queryFn: () => fetchBoard(false), - queryKey: boardKey(slug, false), + queryKey: boardKey(scope, slug, false), refetchInterval: 60_000 }) diff --git a/apps/desktop/src/plugins/kanban/ui.tsx b/apps/desktop/src/plugins/kanban/ui.tsx index 626ee9f17f..37642e2e25 100644 --- a/apps/desktop/src/plugins/kanban/ui.tsx +++ b/apps/desktop/src/plugins/kanban/ui.tsx @@ -17,7 +17,7 @@ import { } from '@hermes/plugin-sdk' import { type ReactNode, useEffect, useState } from 'react' -import { fetchOrchestration, ORCHESTRATION_KEY } from './api' +import { fetchOrchestration, orchestrationKey, useKanbanScope } from './api' import { columnLabel, useKanban } from './i18n' import { columnMeta, type KanbanTask } from './types' @@ -34,7 +34,9 @@ export const $newTaskLane = atom(null) /** Orchestration knobs (cached app-wide; the settings panel invalidates). */ export function useOrchestration() { - return useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration, staleTime: 60_000 }).data + const scope = useKanbanScope() + + return useQuery({ queryKey: orchestrationKey(scope), queryFn: fetchOrchestration, staleTime: 60_000 }).data } /** The dispatcher's configured fallback for unassigned ready cards From e4a1969fd61d0d372ab3a2646e89ef0c5decbdc3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:10:08 +0530 Subject: [PATCH 16/35] fix(desktop): kanban keeps the bare slug key for local and dials once per switch boardSlug. for every connection abandoned the slug existing users had persisted under the bare key and broke the lib/connection-scoped.ts contract (the local connection keeps the bare key, byte-identical storage for single-backend users). Local now reads and writes `boardSlug`; remote connections get `boardSlug.`. On a connection change whose persisted slug differed, hydrating $boardSlug already reopened the socket through the $boardSlug listener, and the listener then dialed a second time. Reopen explicitly only when the slug is unchanged (the backend behind it still changed), so each switch dials once. Test covers both plus the render-time key following the connection without a manual rerender. --- .../kanban/api.connection-scope.test.ts | 80 +++++++++++++++++++ apps/desktop/src/plugins/kanban/api.ts | 45 +++++++---- 2 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/plugins/kanban/api.connection-scope.test.ts diff --git a/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts b/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts new file mode 100644 index 0000000000..0b38b17711 --- /dev/null +++ b/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts @@ -0,0 +1,80 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// A board lives on ONE gateway. The kanban data layer follows the active +// connection: keys built during render change with it (so an observer is a +// clean cache miss on a switch), the selected slug is remembered per +// connection (the local pool keeps the bare key — the lib/connection-scoped.ts +// contract, and the slug picked before per-connection keys existed survives), +// and the events socket dials the new backend exactly once per switch. + +vi.mock('@/hermes', () => ({ setApiRequestProfile: vi.fn() })) + +const { $boardSlug, bindApi, boardsKey, useKanbanScope } = await import('./api') +const { setConnection } = await import('@/store/session') + +afterEach(() => { + setConnection(null) +}) + +describe('kanban connection scope', () => { + it('render-time keys follow the active connection', () => { + const { result } = renderHook(() => useKanbanScope()) + + expect(boardsKey(result.current)).toEqual(['kanban', 'boards', 'local']) + + // No rerender(): the subscription itself must re-render the component. + act(() => setConnection({ connectionId: 'spark', mode: 'remote' } as never)) + + expect(boardsKey(result.current)).toEqual(['kanban', 'boards', 'spark']) + }) + + it('remembers the slug per connection and dials the socket once per switch', () => { + const stored = new Map([ + ['boardSlug', 'ops'], + ['boardSlug.spark', 'research'] + ]) + + const storage = { + get: (key: string, fallback: T) => (stored.has(key) ? (stored.get(key) as T) : fallback), + remove: vi.fn(), + set: (key: string, value: unknown) => void stored.set(key, value) + } + + const dials: string[] = [] + + const socket = vi.fn((path: string) => { + dials.push(path) + + return vi.fn() + }) + + const dispose = bindApi(async () => ({}) as never, storage, socket) + + expect($boardSlug.get()).toBe('ops') + expect(dials).toEqual(['/events?board=ops']) + + // Boot publishes the local descriptor after plugins bound: same scope, no dial. + setConnection({ mode: 'local' } as never) + expect(dials).toEqual(['/events?board=ops']) + + // Different slug on the next gateway: exactly one dial, not one per listener. + setConnection({ connectionId: 'spark', mode: 'remote' } as never) + expect($boardSlug.get()).toBe('research') + expect(dials).toEqual(['/events?board=ops', '/events?board=research']) + + // Same slug on the way back to a gateway with an equal selection still + // dials once — the backend behind the slug changed. + stored.set('boardSlug', 'research') + setConnection({ mode: 'local' } as never) + expect($boardSlug.get()).toBe('research') + expect(dials).toEqual(['/events?board=ops', '/events?board=research', '/events?board=research']) + + // Writes land under the scope current at write time. + $boardSlug.set('triage') + expect(stored.get('boardSlug')).toBe('triage') + expect(stored.get('boardSlug.spark')).toBe('research') + + dispose() + }) +}) diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index 8135c0fde3..3de2173b29 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -152,28 +152,41 @@ export function bindApi( close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data)) } - // Board selection is per-connection: hydrate under the connection's storage - // key, and re-hydrate + reopen the socket when the connection changes (the - // outgoing gateway's slug may 404 on the next one). - const slugStorageKey = () => `${BOARD_SLUG_KEY}.${kanbanConnectionScope()}` + // The local connection keeps the BARE key (same rule as lib/connection-scoped + // for single-backend users: byte-identical storage, and the slug they picked + // before per-connection keys existed survives the upgrade). Remotes are + // suffixed by registry id. + const slugStorageKey = () => { + const scope = kanbanConnectionScope() - const hydrateSlug = () => { - $boardSlug.set(storage.get(slugStorageKey(), '')) + return scope === 'local' ? BOARD_SLUG_KEY : `${BOARD_SLUG_KEY}.${scope}` } - hydrateSlug() + $boardSlug.set(storage.get(slugStorageKey(), '')) unsubs.push($boardSlug.listen(slug => storage.set(slugStorageKey(), slug))) - unsubs.push( - host.state.connectionId.listen(() => { - // Query keys embed the connection scope (kanbanConnectionScope), so the - // new connection is already a clean React Query cache miss — no - // invalidation needed here. Only the LIVE bindings (socket, slug) follow. - hydrateSlug() - open($boardSlug.get()) - }) - ) open($boardSlug.get()) unsubs.push($boardSlug.listen(open)) + let scope = kanbanConnectionScope() + unsubs.push( + host.state.connectionId.listen(() => { + // Query keys embed the scope, so the new connection is already a cache + // miss; only the LIVE bindings (socket, slug) follow it. The boot-time + // null → 'local' publish is the same scope, not a switch. A changed slug + // reopens the socket through the $boardSlug listener above; an unchanged + // slug still needs a dial because the backend behind it changed. + if (kanbanConnectionScope() === scope) { + return + } + + scope = kanbanConnectionScope() + const previous = $boardSlug.get() + $boardSlug.set(storage.get(slugStorageKey(), '')) + + if ($boardSlug.get() === previous) { + open(previous) + } + }) + ) return () => { unsubs.forEach(unsub => unsub()) From 49f47ef64cd5412c8c9f6410985556c21d98b0e3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:10:08 +0530 Subject: [PATCH 17/35] refactor(desktop): connection-scope invalidation is a listen on $activeConnectionId $activeConnectionId is a computed of a primitive: nanostores' set() drops equal values and listen() does not fire on attach, so the undefined sentinel, the change guard and the comment explaining them are already provided by the store. One listener replaces the block riding the $activeConnectionProfile subscription; both connection-scope tests hold. --- apps/desktop/src/store/connections.ts | 32 ++++--------------- .../src/store/kanban-connection-scope.test.ts | 17 ++++++---- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 1cda5c1d34..455844d301 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -77,37 +77,19 @@ const $activeConnectionProfile = computed( // The CONNECTION twin of profile.ts's $activeGatewayProfile subscription. // The switch commit point (beginGatewaySwitch → invalidateProfileScopedQueries) -// runs inside beforeActivate — BEFORE the activation publishes the new request -// scope (applyActive → setApiRequestConnection) — so that invalidation's -// immediate refetches ride the OUTGOING backend and repaint its data under the -// incoming connection's route. When the connection id later moves, no kanban -// query key changes (the pane's keys are unscoped), so nothing re-invalidates: -// the pane keeps painting the previous gateway's boards. Invalidate here, on -// the actual (connectionId) change, so the refetch lands on the backend the -// tags now name. -// -// The profile twin suppresses its FIRST fire via `_lastRoutedProfile !== -// null`, which relies on the profile atom being non-null at startup. The -// connection id is legitimately null on the primary local route, so null -// cannot double as "uninitialized" here — an explicit sentinel does. -let _lastRoutedConnectionId: null | string | undefined +// runs inside beforeActivate — BEFORE applyActive publishes the new request +// scope (setApiRequestConnection) — so that invalidation's refetches ride the +// OUTGOING backend. Re-invalidate on the actual connection-id change, which +// applyActive publishes only after the tag moved, so the refetch lands on the +// backend the tags now name. `listen` (not `subscribe`) skips the mount-time +// fire, and the computed dedupes equal ids, so this only runs on a real switch. +$activeConnectionId.listen(() => invalidateProfileScopedQueries()) /** Remember one profile per source, so switching machines is a re-home rather * than a reset to `default`. The map is local UI preference only; Electron * remains the authority for the connection registry and all secrets. */ $activeConnectionProfile.subscribe(({ connectionId, descriptorProfile, profile, registryScoped }) => { - // The connection twin (above) rides this existing subscription — the one - // listener already holding the computed open — instead of a second - // subscribe, so the store's task graph is unchanged. - const id = connectionId ?? null - - if (_lastRoutedConnectionId !== undefined && _lastRoutedConnectionId !== id) { - invalidateProfileScopedQueries() - } - - _lastRoutedConnectionId = id - // A migrated v1 per-profile remote may expose a client-side alias such as // "work" while the registered source's actual profile is "default". Only // remember a source/profile pair after Electron confirms that exact v2 diff --git a/apps/desktop/src/store/kanban-connection-scope.test.ts b/apps/desktop/src/store/kanban-connection-scope.test.ts index aaaab9b592..f7871cc8be 100644 --- a/apps/desktop/src/store/kanban-connection-scope.test.ts +++ b/apps/desktop/src/store/kanban-connection-scope.test.ts @@ -45,6 +45,7 @@ const { queryClient } = await import('@/lib/query-client') const { closeSecondaryGateways, configureGatewayRegistry, setPrimaryGateway } = await import('@/store/gateway') const { $activeGatewayProfile } = await import('@/store/profile') const { selectConnection, setConnectionsRegistry, _resetConnectionsForTests } = await import('@/store/connections') +const { setConnection } = await import('@/store/session') const conn = (over: Partial = {}): HermesConnection => ({ @@ -116,9 +117,8 @@ describe('connection-switch query invalidation', () => { }) it('refetches connection-scoped queries against the NEW gateway after a switch', async () => { - // One active profile-scoped query, exactly like the kanban pane's boards - // list: the connection tag is read at queryFn time, which is what - // pluginRest → hermesApi does. + // One active profile-scoped query whose connection tag is read at queryFn + // time, which is what pluginRest → hermesApi does. observer = new QueryObserver(queryClient, { queryKey: ['kanban', 'boards'], queryFn: async () => { @@ -161,11 +161,14 @@ describe('connection-switch query invalidation', () => { }) const unsubscribe = observer.subscribe(() => undefined) - await vi.waitFor(() => expect(fetches).toBe(1)) + // Settle, not just start: an invalidation racing an in-flight fetch is + // absorbed by the fetch and would hide a listener that fires too often. + await vi.waitFor(() => expect(observer!.getCurrentResult().status).toBe('success')) + expect(fetches).toBe(1) - // Re-publishing the same registry must not re-invalidate (first-fire - // suppression + change guard, same contract as the profile twin). - setConnectionsRegistry(registry) + // A fresh descriptor object naming the SAME connection id (a resync, not a + // switch) must not re-invalidate. + setConnection(connection => ({ ...connection! })) await new Promise(resolve => setTimeout(resolve, 200)) expect(fetches).toBe(1) From 944e017cbea1c29869482ac83b589cd487142d09 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:52:44 +0530 Subject: [PATCH 18/35] fix(desktop): kanban queries fetch only while keyed to the routed connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a connection switch the request tag moves (applyActive → setApiRequestConnection) before the descriptor publishes, and React re-keys the kanban observers later still. The app-wide invalidations that fire in that window (the connection twin, the profile twin) refetched observers still sitting on the OUTGOING scope's key with the INCOMING tag — writing the new gateway's boards/tasks under the old connection's cache key, which then painted on the way back. Three fetches per query per switch, one of them poison. Install `enabled: routedToScope` as the `['kanban']` query default in bindApi: a kanban query only fetches while its key's scope segment matches where a request is routed right now (`host.activeConnectionId()`). Outgoing observers are skipped; the incoming keys are already a cache miss and fetch once on re-render. The drawer's two `enabled: !!id` sites compose it. Also: the connection listener uses nanostores' previous value instead of a mutable tracker; 'local' literal hoisted to LOCAL_SCOPE; duplicated rationale comments cut. Test: an observer on boardsKey('local') with the route moved to spark is not refetched by invalidateQueries(); back on local it is. Red without the default (`[null, 'spark']`), green with it. --- .../kanban/api.connection-scope.test.ts | 57 +++++++++++++++++-- apps/desktop/src/plugins/kanban/api.ts | 55 +++++++++++------- apps/desktop/src/plugins/kanban/drawer.tsx | 5 +- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts b/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts index 0b38b17711..c29c84eca5 100644 --- a/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts +++ b/apps/desktop/src/plugins/kanban/api.connection-scope.test.ts @@ -1,20 +1,28 @@ +import { QueryObserver } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -// A board lives on ONE gateway. The kanban data layer follows the active -// connection: keys built during render change with it (so an observer is a -// clean cache miss on a switch), the selected slug is remembered per -// connection (the local pool keeps the bare key — the lib/connection-scoped.ts -// contract, and the slug picked before per-connection keys existed survives), -// and the events socket dials the new backend exactly once per switch. +// A board lives on ONE gateway; the kanban data layer follows the active +// connection. See the scope comments in ./api.ts. + +const routed = vi.hoisted(() => ({ id: null as null | string })) vi.mock('@/hermes', () => ({ setApiRequestProfile: vi.fn() })) +vi.mock('@/store/gateway', async importOriginal => ({ + ...(await importOriginal>()), + activeGatewayConnectionId: () => routed.id +})) const { $boardSlug, bindApi, boardsKey, useKanbanScope } = await import('./api') const { setConnection } = await import('@/store/session') +const { queryClient } = await import('@/lib/query-client') + +const noopStorage = { get: (_key: string, fallback: T) => fallback, remove: vi.fn(), set: vi.fn() } afterEach(() => { setConnection(null) + routed.id = null + queryClient.clear() }) describe('kanban connection scope', () => { @@ -77,4 +85,41 @@ describe('kanban connection scope', () => { dispose() }) + + it('an observer still keyed to the outgoing scope is not refetched onto the incoming backend', async () => { + const dispose = bindApi( + async () => ({}) as never, + noopStorage, + vi.fn(() => vi.fn()) + ) + + const fetches: Array = [] + + const observer = new QueryObserver(queryClient, { + queryFn: async () => { + fetches.push(routed.id) + + return { boards: [] } + }, + queryKey: boardsKey('local') + }) + + const unsubscribe = observer.subscribe(() => undefined) + await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success')) + expect(fetches).toEqual([null]) + + // The request tag has moved to spark but React has not re-keyed the + // observer yet: the switch's invalidation must skip it. + routed.id = 'spark' + await queryClient.invalidateQueries() + expect(fetches).toEqual([null]) + + // Back on local the same observer is live again. + routed.id = null + await queryClient.invalidateQueries() + expect(fetches).toEqual([null, null]) + + unsubscribe() + dispose() + }) }) diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index 3de2173b29..6fd0d997cc 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -63,6 +63,11 @@ export const $lanesByProfile = atom(false) * auto: empty lanes collapse to a rail, occupied lanes expand. Persisted. */ export const $collapsedLanes = atom>({}) +/** Cache scope of the local pool — the SDK atom's own spelling. */ +const LOCAL_SCOPE = 'local' + +const KANBAN_KEY_ROOT = ['kanban'] as const + const BOARD_SLUG_KEY = 'boardSlug' const INTRO_KEY = 'introDismissed' const LANES_KEY = 'lanesByProfile' @@ -74,13 +79,27 @@ const COLLAPSED_KEY = 'collapsedLanes' * socket frames); rendering components use `useKanbanScope` so the keys they * build during render recompute when the connection changes. */ export function kanbanConnectionScope(): string { - return host.state.connectionId.get() ?? 'local' + return host.state.connectionId.get() ?? LOCAL_SCOPE } export function useKanbanScope(): string { - return useValue(host.state.connectionId) ?? 'local' + return useValue(host.state.connectionId) ?? LOCAL_SCOPE } +/** Where a request issued NOW is routed, as a cache scope. The request tag + * moves before the connection descriptor publishes, and React re-keys the + * observers later still — so between the two an observer can sit on the + * outgoing scope's key while a fetch would land on the incoming backend. */ +const routedScope = (): string => host.activeConnectionId() ?? LOCAL_SCOPE + +/** `enabled` for every kanban query: only fetch while the key's scope is the + * routed one. A switch's app-wide invalidation then leaves the outgoing + * observers alone (the incoming keys are already a cache miss) instead of + * writing the new gateway's payload under the old connection's key — which + * would paint on the way back. Installed as the `['kanban']` query default in + * `bindApi`; sites with their own `enabled` compose it. */ +export const routedToScope = (query: { queryKey: readonly unknown[] }): boolean => query.queryKey[2] === routedScope() + /** One live `task_events` frame → precise cache invalidation: the board, plus * each touched task's detail. The polls (8s board / 4s drawer) stay as the * fallback — the socket just makes the board feel instant. */ @@ -116,14 +135,7 @@ interface Persisted { /** Bind the plugin's doors at register time and return a disposer the host * runs on unload/disable — so nothing (store sync, socket) survives a toggle * or duplicates on re-enable. The events socket is pinned to a board at - * handshake, so a board switch closes + reopens it. - * - * The events socket is ALSO re-opened when the active CONNECTION changes: - * `pluginSocket` resolves the backend only at connect time, so a socket left - * open across a switch keeps streaming the previous gateway's events against - * the new connection's request scope. The board slug re-hydrates from the - * per-connection storage key for the same reason — one gateway's slug pins a - * board the next gateway 404s on. */ + * handshake, so a board switch closes + reopens it. */ export function bindApi( r: Rest, storage: PluginStorage, @@ -135,6 +147,9 @@ export function bindApi( bindCompletionNotify(r, notifyDoors?.t, notifyDoors?.os) const unsubs: Array<() => void> = [] + queryClient.setQueryDefaults(KANBAN_KEY_ROOT, { enabled: routedToScope }) + unsubs.push(() => queryClient.setQueryDefaults(KANBAN_KEY_ROOT, {})) + // Hydrate an atom from storage and keep storage in sync with it. const persist = (atom: Persisted, key: string, fallback: T) => { atom.set(storage.get(key, fallback)) @@ -152,33 +167,31 @@ export function bindApi( close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data)) } - // The local connection keeps the BARE key (same rule as lib/connection-scoped - // for single-backend users: byte-identical storage, and the slug they picked - // before per-connection keys existed survives the upgrade). Remotes are - // suffixed by registry id. + // The local connection keeps the BARE key (the bare-local rule of + // lib/connection-scoped: byte-identical storage for single-backend users, and + // the slug picked before per-connection keys existed survives the upgrade). + // Remotes are suffixed by registry id. const slugStorageKey = () => { const scope = kanbanConnectionScope() - return scope === 'local' ? BOARD_SLUG_KEY : `${BOARD_SLUG_KEY}.${scope}` + return scope === LOCAL_SCOPE ? BOARD_SLUG_KEY : `${BOARD_SLUG_KEY}.${scope}` } $boardSlug.set(storage.get(slugStorageKey(), '')) unsubs.push($boardSlug.listen(slug => storage.set(slugStorageKey(), slug))) open($boardSlug.get()) unsubs.push($boardSlug.listen(open)) - let scope = kanbanConnectionScope() unsubs.push( - host.state.connectionId.listen(() => { + host.state.connectionId.listen((next, prev) => { // Query keys embed the scope, so the new connection is already a cache // miss; only the LIVE bindings (socket, slug) follow it. The boot-time // null → 'local' publish is the same scope, not a switch. A changed slug // reopens the socket through the $boardSlug listener above; an unchanged // slug still needs a dial because the backend behind it changed. - if (kanbanConnectionScope() === scope) { + if ((next ?? LOCAL_SCOPE) === (prev ?? LOCAL_SCOPE)) { return } - scope = kanbanConnectionScope() const previous = $boardSlug.get() $boardSlug.set(storage.get(slugStorageKey(), '')) @@ -218,9 +231,7 @@ function withBoard(path: string, params: Record = {}): string { return qs ? `${path}?${qs}` : path } -// ── query keys (connection- AND board-scoped so switching either is a clean -// cache miss — one gateway's boards/tasks must never paint under another -// connection's route) ───────────────────────────────────────────────────────── +// ── query keys (connection- and board-scoped; scope is always segment [2]) ──── /** Prefix matching every board query on one connection (all slugs, both * archived views) — the mutation-settled invalidation target. */ diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx index 807e471a8e..0c714fa86b 100644 --- a/apps/desktop/src/plugins/kanban/drawer.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.tsx @@ -44,6 +44,7 @@ import { profilesKey, reassignTask, reclaimTask, + routedToScope, taskKey, uploadAttachment, useKanbanScope @@ -559,7 +560,7 @@ export function TaskDrawer({ // Socket-invalidated (bindApi); the interval is only the socketless heartbeat. const { data: detail, error } = useQuery({ - enabled: !!id, + enabled: query => !!id && routedToScope(query), queryFn: () => fetchTask(id!), queryKey: taskKey(scope, slug, id ?? ''), refetchInterval: 30_000 @@ -570,7 +571,7 @@ export function TaskDrawer({ const defaultAssignee = useDefaultAssignee() const { data: log } = useQuery({ - enabled: !!id, + enabled: query => !!id && routedToScope(query), queryFn: () => fetchLog(id!), queryKey: logKey(scope, slug, id ?? ''), refetchInterval: running ? 3_000 : 15_000 From e175ee3a4db10872c28dd5db83a6013912d72eb6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:52:44 +0530 Subject: [PATCH 19/35] test(desktop): trim kanban scope tests to their invariants Store test header points at the store/connections.ts comment instead of restating it; drop the two assertions implied by the final-tag check; drawer test invalidates via taskKey() instead of a raw prefix literal. Revert the comment-style churn on the unrelated $activeConnectionProfile subscription. --- apps/desktop/src/plugins/kanban/drawer.test.tsx | 4 ++-- apps/desktop/src/store/connections.ts | 7 +++---- .../src/store/kanban-connection-scope.test.ts | 16 ++-------------- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/plugins/kanban/drawer.test.tsx b/apps/desktop/src/plugins/kanban/drawer.test.tsx index bb7d5d5aa5..d834d262d2 100644 --- a/apps/desktop/src/plugins/kanban/drawer.test.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // eslint-disable-next-line no-restricted-imports import { registerPluginLocales } from '@/i18n/plugin-i18n' -import { bindApi } from './api' +import { bindApi, taskKey } from './api' import { TaskDrawer } from './drawer' import { en, KANBAN_LOCALES } from './i18n' import type { KanbanTaskDetail } from './types' @@ -94,7 +94,7 @@ describe('task attachment compatibility', () => { // A later backend response restores the capability without remounting. detail = { ...legacyDetail, attachments: [] } - await act(() => client.invalidateQueries({ queryKey: ['kanban', 'task'] })) + await act(() => client.invalidateQueries({ queryKey: taskKey('local', '', legacyDetail.task.id) })) expect(await screen.findByRole('button', { name: en.uploadAttachment })).toBeTruthy() expect(screen.getByText(en.noAttachments)).toBeTruthy() } diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 455844d301..0a0809127a 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -85,10 +85,9 @@ const $activeConnectionProfile = computed( // fire, and the computed dedupes equal ids, so this only runs on a real switch. $activeConnectionId.listen(() => invalidateProfileScopedQueries()) -/** Remember one profile per source, so switching machines is a re-home rather - * than a reset to `default`. The map is local UI preference only; Electron - * remains the authority for the connection registry and all secrets. - */ +// Remember one profile per source, so switching machines is a re-home rather +// than a reset to `default`. The map is local UI preference only; Electron +// remains the authority for the connection registry and all secrets. $activeConnectionProfile.subscribe(({ connectionId, descriptorProfile, profile, registryScoped }) => { // A migrated v1 per-profile remote may expose a client-side alias such as // "work" while the registered source's actual profile is "default". Only diff --git a/apps/desktop/src/store/kanban-connection-scope.test.ts b/apps/desktop/src/store/kanban-connection-scope.test.ts index f7871cc8be..83015cedd8 100644 --- a/apps/desktop/src/store/kanban-connection-scope.test.ts +++ b/apps/desktop/src/store/kanban-connection-scope.test.ts @@ -4,16 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesConnection } from '@/global' // A connection switch must leave every profile-scoped query refetched against -// the NEW gateway. The switch commit point (beginGatewaySwitch → -// wipeSessionListsForGatewaySwitch → invalidateProfileScopedQueries) runs -// inside beforeActivate — BEFORE the activation publishes the new request -// scope (applyActive → setApiRequestConnection) — so that invalidation's -// refetches ride the OUTGOING backend. When the connection id later moves and -// the profile atom is unchanged, nothing re-invalidates: a kanban pane (or -// any connection-scoped query) keeps painting the previous gateway's data. -// store/connections closes the hole with the CONNECTION twin of profile.ts's -// $activeGatewayProfile subscription: invalidate on the actual connection-id -// change, so the refetch lands on the backend the tags now name. +// the NEW gateway — see the $activeConnectionId.listen comment in +// store/connections.ts for why the switch's own wipe is not enough. // // Real store chain (store/gateway + store/profile + store/connections), only // the HermesGateway socket class stubbed — same harness as @@ -69,7 +61,6 @@ const registry = { describe('connection-switch query invalidation', () => { let tagsAtFetch: Array - let observer: QueryObserver | undefined beforeEach(() => { @@ -142,9 +133,6 @@ describe('connection-switch query invalidation', () => { // spark-tagged. await vi.waitFor(() => expect(tagsAtFetch.at(-1)).toBe('spark'), { timeout: 2_000 }) - expect(getApiRequestConnection()).toBe('spark') - expect(tagsAtFetch).toContain('spark') - unsubscribe() }) From 4094ab610dc7f8554461e7a9b3ffe5e419220949 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:08:30 +0530 Subject: [PATCH 20/35] hermes_platform.resolver: locate/inspect/probe tiers, AppResolver, gh lookup migrated (NS-921) (#118065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(platform): resolver core with locate/inspect/probe tiers and ordered candidates Every resource lookup needs one result shape and one cost contract. `locate` reads metadata only, `inspect` may open files and call OS APIs in-process, `probe` is fresh and the only tier that may spawn or connect. `Resolution.candidates` keeps probe order so fan-out consumers can try every present binary. Linear NS-921. * feat(platform): AppResolver over AppDef with plist, PE, registry, and server.json sources Desktop apps need presence, version, and liveness as separate observations. The runtime file's bearer token is parsed, used for one request, and discarded inside the probe; no public type carries it. Endpoints are accepted only when loopback with a numeric port. * refactor(copilot): gh candidates through locate_command and the Homebrew table First consumer of the resolver. The gh token probe still tries every present binary in order; the allowlist loses its two copilot_auth rows. * feat(platform): availability() over an application declaration locate() + inspect() only, never probes; the fail-closed _version in app.py treats a vendor's plist/PE/registry entry as untrusted input. Salvaged from PR #118122; reads any object with requires_app, min_version, app_for(os) — nothing here imports the MCP catalog. * feat(platform): application declarations parsed into AppDef per OS The parser slice of PR #118122's catalog manifest, re-homed as a catalog-free module: whoever owns an MCP server declares the app it fronts per OS and what it needs, and registers it here. Stdlib + hermes_platform.resolver only. register/lookup/clear are the one seam the MCP check_fn and the skill gate both read. * feat(mcp): check_fn honours a registered application declaration _make_check_fn ANDs the declared app's availability into the connection-alive check; with nothing registered for the server the behaviour is the pre-PR3 connection check. Provenance is explicit registration, not endpoint matching. Returns a plain bool: the registry caches bool(fn()). * feat(skills): requires_apps gate through registered declarations Offer-time filter beside environments:; names resolve through hermes_platform.declaration, an unknown name hides the skill (fail closed). The disk snapshot carries requires_apps and the fast path re-evaluates it (snapshot version bumped to 3): app presence is a host fact that changes without SKILL.md changing. * docs: application declarations page The plugin-facing schema reference: app: and requires: blocks, availability() states, and the two gates that read the registry. Registered under Extending > Plugins in the docs sidebar. * test(platform): declaration parser, availability, gates The PR3 app-block tests re-homed off the catalog: fixtures are dicts passed to parse_declaration, the check_fn gate keys on explicit registration (not endpoint matching), and the import-hygiene probe now covers hermes_platform.declaration and resolver.availability. --- agent/prompt_builder.py | 26 +- agent/skill_commands.py | 4 +- agent/skill_utils.py | 22 ++ hermes_cli/copilot_auth.py | 16 +- hermes_platform/catalog/__init__.py | 1 - hermes_platform/declaration.py | 213 +++++++++++++ hermes_platform/resolver/__init__.py | 18 +- hermes_platform/resolver/app.py | 301 ++++++++++++++++++ hermes_platform/resolver/availability.py | 94 ++++++ hermes_platform/resolver/base.py | 55 ++++ hermes_platform/resolver/core.py | 148 +++++++++ hermes_platform/resolver/known_dirs.py | 43 +++ pyproject.toml | 2 - tests/agent/test_skill_app_snapshot.py | 42 +++ tests/fixtures/resolution_allowlist.json | 10 - tests/hermes_cli/test_api_key_providers.py | 22 +- tests/hermes_platform/test_declaration.py | 172 ++++++++++ tests/hermes_platform/test_import_hygiene.py | 7 +- tests/hermes_platform/test_resolver_app.py | 218 +++++++++++++ tests/hermes_platform/test_resolver_core.py | 115 +++++++ tests/tools/test_mcp_app_gate.py | 48 +++ tools/mcp_tool_handlers.py | 32 +- tools/skills_tool.py | 3 +- .../plugins/application-declarations.md | 99 ++++++ website/sidebars.ts | 1 + 25 files changed, 1667 insertions(+), 45 deletions(-) delete mode 100644 hermes_platform/catalog/__init__.py create mode 100644 hermes_platform/declaration.py create mode 100644 hermes_platform/resolver/app.py create mode 100644 hermes_platform/resolver/availability.py create mode 100644 hermes_platform/resolver/base.py create mode 100644 hermes_platform/resolver/core.py create mode 100644 hermes_platform/resolver/known_dirs.py create mode 100644 tests/agent/test_skill_app_snapshot.py create mode 100644 tests/hermes_platform/test_declaration.py create mode 100644 tests/hermes_platform/test_resolver_app.py create mode 100644 tests/hermes_platform/test_resolver_core.py create mode 100644 tests/tools/test_mcp_app_gate.py create mode 100644 website/docs/developer-guide/plugins/application-declarations.md diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 8ddbeb8903..5f33063912 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -24,7 +24,7 @@ from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( EXCLUDED_SKILL_DIRS, ORG_ACTIVE_MARKER, ORG_MIRROR_DIR_NAME, ORG_PROVENANCE_FILE, SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, get_disabled_skill_names, - iter_skill_index_files, parse_frontmatter, read_active_org_id, skill_matches_environment, + iter_skill_index_files, parse_frontmatter, read_active_org_id, skill_matches_apps, skill_matches_environment, skill_matches_platform, skill_matches_platform_list, ) from tools.threat_patterns import scan_for_threats as _scan_for_threats @@ -1116,7 +1116,7 @@ _SKILLS_PROMPT_CACHE_MAX = 32 _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict() _SKILLS_PROMPT_CACHE_LOCK = threading.Lock() # v2 added org provenance fields (org_id/org_author); older snapshots are rebuilt. -_SKILLS_SNAPSHOT_VERSION = 2 +_SKILLS_SNAPSHOT_VERSION = 3 def _skills_prompt_snapshot_path() -> Path: @@ -1177,6 +1177,12 @@ def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]: return None +def _requires_apps_list(frontmatter: dict) -> list[str]: + raw = frontmatter.get("requires_apps") + items = raw if isinstance(raw, list) else [raw] if raw else [] + return [str(a).strip() for a in items if str(a).strip()] + + def _build_snapshot_entry(skill_file: Path, skills_dir: Path, frontmatter: dict, description: str) -> dict: """Serialisable metadata dict for one skill.""" parts = skill_file.relative_to(skills_dir).parts @@ -1192,6 +1198,7 @@ def _build_snapshot_entry(skill_file: Path, skills_dir: Path, frontmatter: dict, "skill_name": skill_name, "category": category, "frontmatter_name": str(frontmatter.get("name", skill_name)), "description": description, "platforms": [str(p).strip() for p in platforms if str(p).strip()], "conditions": extract_skill_conditions(frontmatter), + "requires_apps": _requires_apps_list(frontmatter), } if org_id: entry["org_id"] = org_id @@ -1208,8 +1215,8 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: try: frontmatter, _ = parse_frontmatter(skill_file.read_text(encoding="utf-8")) # Host-platform / runtime-environment gates are offer-time only; explicit loads bypass them. - if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter): - return False, frontmatter, "" + if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter) or not skill_matches_apps(frontmatter): + return False, frontmatter, extract_skill_description(frontmatter) return True, frontmatter, extract_skill_description(frontmatter) except Exception as e: logger.warning("Failed to parse skill file %s: %s", skill_file, e) @@ -1414,9 +1421,13 @@ def _build_skills_system_prompt_inner( _platform_hint, tuple(sorted(disabled)), tuple(sorted(compact_categories or ())), _oneshot_prompt_variant(), ) + snapshot = _load_skills_snapshot(skills_dir) + app_gated = snapshot is not None and any( + entry.get("requires_apps") for entry in snapshot.get("skills", []) if isinstance(entry, dict) + ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) - if cached is not None: + if cached is not None and not app_gated: _SKILLS_PROMPT_CACHE.move_to_end(cache_key) return cached @@ -1428,9 +1439,10 @@ def _build_skills_system_prompt_inner( skills_by_category: dict[str, list[tuple[str, str]]] = {} category_descriptions: dict[str, str] = {} # Disk snapshot (fast path) vs. full scan: both yield (entry, is_compatible) pairs so labeling runs identically. - snapshot = _load_skills_snapshot(skills_dir) if snapshot is not None: - candidates = [(entry, skill_matches_platform_list(entry.get("platforms") or [])) + # Platforms and app presence are host facts that change without SKILL.md changing: re-evaluate both. + candidates = [(entry, skill_matches_platform_list(entry.get("platforms") or []) + and skill_matches_apps({"requires_apps": entry.get("requires_apps") or []})) for entry in snapshot.get("skills", []) if isinstance(entry, dict)] category_descriptions = {str(k): str(v) for k, v in (snapshot.get("category_descriptions") or {}).items()} else: diff --git a/agent/skill_commands.py b/agent/skill_commands.py index ec17a02d9a..7f3de81148 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -353,12 +353,12 @@ def skill_command_collision_note(name: str) -> Optional[str]: def _scan_skill_md(skill_md: Path, disabled: set, seen_names: set, commands: Dict[str, Dict[str, Any]]) -> None: """Register one SKILL.md in *commands* (no-op when filtered or colliding).""" - from tools.skills_tool import _parse_frontmatter, skill_matches_platform, skill_matches_environment + from tools.skills_tool import _parse_frontmatter, skill_matches_apps, skill_matches_platform, skill_matches_environment if any(part in _SCAN_SKIP_PARTS for part in skill_md.parts): return frontmatter, body = _parse_frontmatter(skill_md.read_text(encoding='utf-8')) # OS gate is hard; environment gate (kanban/docker/s6) is offer-time only. - if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter): + if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter) or not skill_matches_apps(frontmatter): return name = frontmatter.get('name', skill_md.parent.name) if name in seen_names or name in disabled: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index e9677cae30..ad0eaebe9c 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -208,6 +208,28 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool: return any(_detect_environment(tag) for tag in tags if tag) +def skill_matches_apps(frontmatter: Dict[str, Any]) -> bool: + """True when every app named in ``requires_apps:`` has a registered declaration this host satisfies. + + Names resolve through ``hermes_platform.declaration`` (registered by whoever owns the server, + e.g. the plugin loader); the check is the same ``availability()`` the MCP check_fn uses. An + unknown name hides the skill (fail closed). Offer-time filter, like ``environments:``. + """ + names = frontmatter.get("requires_apps") + if not names: + return True + from hermes_platform import declaration + from hermes_platform.resolver.availability import availability + + for name in names if isinstance(names, list) else [names]: + decl = declaration.lookup(str(name).strip()) + if decl is None or decl.app is None: + return False + if not availability(decl).offerable: + return False + return True + + _RAW_CONFIG_CACHE: Dict[Tuple[str, int, int, int, int], Dict[str, Any]] = {} diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 1f7009fb3e..53c9bc6dfc 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -87,12 +87,16 @@ def resolve_copilot_token() -> tuple[str, str]: def _gh_cli_candidates() -> list[str]: - """Candidate ``gh`` binary paths, including common Homebrew installs.""" - candidates: list[str] = [c for c in (shutil.which("gh"),) if c] - candidates += [ - c for c in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh", str(Path.home() / ".local/bin/gh")) - if c not in candidates and os.path.isfile(c) and os.access(c, os.X_OK)] - return candidates + """Every present ``gh`` in probe order: PATH first, then Homebrew and ``~/.local/bin``.""" + from hermes_platform.resolver import locate_command + from hermes_platform.resolver.known_dirs import homebrew_dirs, user_local_bin + + res = locate_command("gh", known_dirs=(*homebrew_dirs(), *user_local_bin())) + seen: list[str] = [] + for cand in res.present: + if cand.value not in seen: + seen.append(cand.value) + return seen # ``gh auth token`` cache (misses too). With no credential store the probe blocks its full 5s on diff --git a/hermes_platform/catalog/__init__.py b/hermes_platform/catalog/__init__.py deleted file mode 100644 index 0e82da6a07..0000000000 --- a/hermes_platform/catalog/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Declarative application catalog and loader.""" diff --git a/hermes_platform/declaration.py b/hermes_platform/declaration.py new file mode 100644 index 0000000000..cc2ef5b2cf --- /dev/null +++ b/hermes_platform/declaration.py @@ -0,0 +1,213 @@ +"""Parse and register application requirements shared by MCP and skill offer-time gates.""" + +from __future__ import annotations + +import re +import threading +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Mapping, Optional + +from hermes_platform.resolver.app import AppDef + +__all__ = [ + "AppSpec", + "RequiresSpec", + "Declaration", + "DeclarationError", + "parse_app", + "parse_requires", + "parse_declaration", + "register", + "unregister", + "lookup", + "clear", +] + + +class DeclarationError(ValueError): + """Declaration parse/validation failure.""" + + +@dataclass(frozen=True) +class AppSpec: + """Where the application lives, one ``AppDef`` per OS it is detectable on.""" + + per_os: Mapping[str, AppDef] = field(default_factory=dict) + + def for_os(self, os_family: str) -> Optional[AppDef]: + return self.per_os.get(os_family) + + +@dataclass(frozen=True) +class RequiresSpec: + """What the server needs before it is offered. Separate from ``app`` on purpose: one is data, one is policy.""" + + app: bool = False + min_version: Optional[str] = None + + +@dataclass(frozen=True) +class Declaration: + """A parsed declaration; satisfies ``hermes_platform.resolver.availability._HasRequirements``.""" + + name: str + app: Optional[AppSpec] + requires: RequiresSpec = RequiresSpec() + + @property + def requires_app(self) -> bool: + return self.requires.app + + @property + def min_version(self) -> Optional[str]: + return self.requires.min_version + + def app_for(self, os_family: str) -> Optional[AppDef]: + return self.app.for_os(os_family) if self.app else None + + +_APP_OS_FAMILIES = ("win32", "darwin", "linux") +_APP_PRESENCE = ("executable", "bundle") +_APP_VERSION_KINDS = {"pe_resource": "win32", "uninstall_registry": "win32", "plist": "darwin", "none": None} +_APP_LIVENESS_KINDS = ("server_json", "none") +_VERSION_RE = re.compile(r"^\d+(\.\d+)*$") + + +def _require_mapping(where: str, key: str, raw: Any) -> dict: + if not isinstance(raw, dict): + raise DeclarationError(f"{where}: '{key}' must be a mapping") + return raw + + +def _location_is_rooted(location: str, osf: str) -> bool: + if ".." in location.replace("\\", "/").split("/") or "://" in location: + return False + if location.startswith(("~", "%", "$")): + return True + if osf == "win32": + return bool(re.match(r"^[A-Za-z]:[\\/]", location)) + return location.startswith("/") + + +def _parse_app_os(where: str, name: str, osf: str, raw: Any) -> AppDef: + _require_mapping(where, f"app.{osf}", raw) + presence = raw.get("presence") + if presence not in _APP_PRESENCE: + raise DeclarationError(f"{where}: app.{osf}.presence must be one of {_APP_PRESENCE}") + location = raw.get("location") + if not isinstance(location, str) or not location.strip(): + raise DeclarationError(f"{where}: app.{osf}.location is required") + if not _location_is_rooted(location.strip(), osf): + raise DeclarationError( + f"{where}: app.{osf}.location must be absolute or start with ~ / %VAR% / $VAR, without '..' or a URL scheme") + version = raw.get("version") or {"kind": "none"} + _require_mapping(where, f"app.{osf}.version", version) + vkind = version.get("kind", "none") + if vkind not in _APP_VERSION_KINDS: + raise DeclarationError(f"{where}: app.{osf}.version.kind must be one of {sorted(_APP_VERSION_KINDS)}") + only_on = _APP_VERSION_KINDS[vkind] + if only_on and only_on != osf: + raise DeclarationError(f"{where}: app.{osf}.version.kind {vkind!r} is only valid under app.{only_on}") + varg = str(version.get("display_name_prefix") or "") + if vkind == "uninstall_registry" and not varg: + raise DeclarationError(f"{where}: app.{osf}.version.display_name_prefix is required for uninstall_registry") + liveness = raw.get("liveness") or {"kind": "none"} + _require_mapping(where, f"app.{osf}.liveness", liveness) + lkind = liveness.get("kind", "none") + if lkind not in _APP_LIVENESS_KINDS: + raise DeclarationError(f"{where}: app.{osf}.liveness.kind must be one of {_APP_LIVENESS_KINDS}") + lpath = str(liveness.get("path") or "") + if lkind == "server_json" and not lpath: + raise DeclarationError(f"{where}: app.{osf}.liveness.path is required for server_json") + return AppDef( + app_id=name, os_family=osf, presence=presence, location=location.strip(), + version_kind=vkind, version_arg=varg, + liveness_kind=lkind, liveness_path=lpath, + liveness_pid_key=str(liveness.get("pid_key") or "pid"), + liveness_url_key=str(liveness.get("url_key") or "http"), + liveness_token_key=str(liveness.get("token_key") or "token"), + endpoint_path=str(liveness.get("endpoint_path") or "/mcp"), + ) + + +def parse_app(raw: Any, *, name: str, where: str) -> Optional[AppSpec]: + """Parse the ``app:`` block (already-decoded mapping, not YAML text) into an ``AppSpec``.""" + if raw is None: + return None + _require_mapping(where, "app", raw) + unknown = set(raw) - set(_APP_OS_FAMILIES) + if unknown: + raise DeclarationError(f"{where}: app has unknown OS keys {sorted(unknown)}; use {_APP_OS_FAMILIES}") + if not raw: + raise DeclarationError(f"{where}: app needs at least one OS block") + return AppSpec(per_os={osf: _parse_app_os(where, name, osf, raw[osf]) for osf in raw}) + + +def parse_requires(raw: Any, app: Optional[AppSpec], *, where: str) -> RequiresSpec: + """Parse the ``requires:`` block; needs the parsed ``app`` to cross-check version policy.""" + if raw is None: + return RequiresSpec() + _require_mapping(where, "requires", raw) + unknown = set(raw) - {"app", "min_version"} + if unknown: + raise DeclarationError(f"{where}: requires has unknown keys {sorted(unknown)}") + needs_app = raw.get("app", False) + if not isinstance(needs_app, bool): + raise DeclarationError(f"{where}: requires.app must be a boolean") + if needs_app and app is None: + raise DeclarationError(f"{where}: requires.app is true but the declaration has no 'app' block") + min_version = raw.get("min_version") + if min_version is not None: + if not isinstance(min_version, str) or not _VERSION_RE.fullmatch(min_version): + raise DeclarationError(f"{where}: requires.min_version must be a dotted numeric string") + if not needs_app: + raise DeclarationError(f"{where}: requires.min_version needs requires.app: true") + assert app is not None + unversioned = sorted(osf for osf, d in app.per_os.items() if d.version_kind == "none") + if unversioned: + raise DeclarationError( + f"{where}: requires.min_version needs a version source under app.{unversioned[0]} (kind is none)") + return RequiresSpec(app=needs_app, min_version=min_version) + + +def parse_declaration(name: str, raw_app: Any, raw_requires: Any, *, where: str) -> Declaration: + """Parse both blocks of one declaration. ``where`` is the human label in error messages.""" + app = parse_app(raw_app, name=name, where=where) + return Declaration(name=name, app=app, requires=parse_requires(raw_requires, app, where=where)) + + +_REGISTRY: Dict[str, Declaration] = {} +_REGISTRY_LOCK = threading.Lock() +on_change: Optional[Callable[[], None]] = None + + +def _changed() -> None: + if on_change is not None: + on_change() + + +def register(server_name: str, decl: Declaration) -> None: + """Record which application declaration gates *server_name*.""" + with _REGISTRY_LOCK: + _REGISTRY[server_name] = decl + _changed() + + +def unregister(server_name: str) -> None: + """Remove the declaration registered for *server_name*.""" + with _REGISTRY_LOCK: + _REGISTRY.pop(server_name, None) + _changed() + + +def lookup(server_name: str) -> Optional[Declaration]: + """Return the declaration registered for *server_name*, if any.""" + with _REGISTRY_LOCK: + return _REGISTRY.get(server_name) + + +def clear() -> None: + """Drop every registration.""" + with _REGISTRY_LOCK: + _REGISTRY.clear() + _changed() diff --git a/hermes_platform/resolver/__init__.py b/hermes_platform/resolver/__init__.py index e3c34bf9d2..cd1b39abdd 100644 --- a/hermes_platform/resolver/__init__.py +++ b/hermes_platform/resolver/__init__.py @@ -1 +1,17 @@ -"""Resource owners and resolution helpers.""" +"""Resource resolution: where a tool or application is, and what state it is in.""" + +from hermes_platform.resolver.base import Effort, Inspection, Probe, Probeable, Resolver +from hermes_platform.resolver.core import ( + ABSENT, + Candidate, + CheckState, + LookupContext, + Observation, + Resolution, + locate_command, +) + +__all__ = [ + "ABSENT", "Candidate", "CheckState", "Effort", "Inspection", "LookupContext", + "Observation", "Probe", "Probeable", "Resolution", "Resolver", "locate_command", +] diff --git a/hermes_platform/resolver/app.py b/hermes_platform/resolver/app.py new file mode 100644 index 0000000000..82549ee5b7 --- /dev/null +++ b/hermes_platform/resolver/app.py @@ -0,0 +1,301 @@ +"""Desktop-application resolver over an `AppDef` (parsed from an MCP manifest's `app:` block). + +`locate` stats the executable or bundle. `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 json +import os +import plistlib +import sys +import time +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urlsplit + +from hermes_platform.resolver.base import Effort, Inspection, Probe +from hermes_platform.resolver.core import ( + Candidate, + CheckState, + LookupContext, + Observation, + Resolution, +) + +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 AppDef: + """One application on one OS. Paths use `%VAR%` and `~`; expansion happens at lookup.""" + + app_id: str + os_family: str + presence: PresenceKind + location: str + 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 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 + target = _expand(d.location) + if not os.path.isabs(target) or "%" in target or "$" in target: + return Resolution("missing", (Candidate(target, f"app:{d.app_id}", False),)) + if d.presence == "bundle": + present = os.path.isdir(target) and os.path.isfile(os.path.join(target, "Contents", "Info.plist")) + else: + present = os.path.isfile(target) + cand = Candidate(target, f"app:{d.app_id}", present) + return Resolution("known_path" if present else "missing", (cand,)) + + # ---- 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 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) + + +# ---- 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_registry_version(display_name_prefix: str) -> Observation[str]: + if sys.platform != "win32": + return Observation(CheckState.UNAVAILABLE, detail="uninstall_registry needs Windows") + 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): + sub = winreg.EnumKey(key, i) + with winreg.OpenKey(key, sub) as entry: + try: + name, _ = winreg.QueryValueEx(entry, "DisplayName") + except OSError: + continue + if str(name).startswith(display_name_prefix): + try: + version, _ = winreg.QueryValueEx(entry, "DisplayVersion") + except OSError: + return Observation(CheckState.UNAVAILABLE, detail="entry has no DisplayVersion") + return Observation(CheckState.PRESENT, str(version)) + except OSError: + continue + return Observation(CheckState.ABSENT, detail="no uninstall entry") + + +# ---- 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=)" + + +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}") diff --git a/hermes_platform/resolver/availability.py b/hermes_platform/resolver/availability.py new file mode 100644 index 0000000000..1827160e31 --- /dev/null +++ b/hermes_platform/resolver/availability.py @@ -0,0 +1,94 @@ +"""Whether a catalog entry can be offered on this host, from its `app` and `requires` blocks. + +`availability` runs `locate` and `inspect` 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 CheckState + +AvailabilityState = Literal[ + "available", + "installed_not_running", + "missing_app", + "version_too_old", + "unsupported_os", + "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: ... + + 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_at_least(found: str, minimum: str) -> bool: + """Compare dot-separated decimal components, failing closed on invalid input.""" + + def 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(".")] + + a, b = parts(found), 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: + if not entry.requires_app: + return Availability("no_requirements") + osf = os_family or facts.os_family() + definition = entry.app_for(osf) + if definition is None: + return Availability("unsupported_os", min_version=entry.min_version) + 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) + version = None + if entry.min_version or definition.version_kind != "none": + obs = resolver.inspect(res).version + version = obs.value if obs.state is CheckState.PRESENT else None + if entry.min_version and (version is None or not version_at_least(version, entry.min_version)): + return Availability("version_too_old", version=version, path=looked_at, min_version=entry.min_version) + return Availability("available", version=version, path=looked_at, min_version=entry.min_version) diff --git a/hermes_platform/resolver/base.py b/hermes_platform/resolver/base.py new file mode 100644 index 0000000000..72197f875d --- /dev/null +++ b/hermes_platform/resolver/base.py @@ -0,0 +1,55 @@ +"""Resolver protocols and the results of the two non-passive tiers. + +`locate` reads file metadata only. `inspect` may open files and call OS APIs in-process. +`probe` is fresh, never cached, and the only tier that may open a socket or spawn. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol, runtime_checkable + +from hermes_platform.resolver.core import LookupContext, Observation, Resolution + + +class Effort(Enum): + """What `probe` may do. `NETWORK` never implies subprocess; `DIAGNOSTIC` alone may spawn.""" + + LOCAL = "local" + NETWORK = "network" + DIAGNOSTIC = "diagnostic" + + +@dataclass(frozen=True) +class Inspection: + version: Observation[str] + signer: Observation[str] + + @property + def ok(self) -> bool: + return self.version.state.value != "error" and self.signer.state.value != "error" + + +@dataclass(frozen=True) +class Probe: + running: Observation[bool] + answering: Observation[bool] + endpoint: Observation[str] + + def __bool__(self) -> bool: # pragma: no cover - the raise is the behavior + raise TypeError("Probe is not a boolean; read .running/.answering") + + +@runtime_checkable +class Resolver(Protocol): + name: str + + def locate(self, ctx: LookupContext | None = None) -> Resolution: ... + + def inspect(self, res: Resolution, ctx: LookupContext | None = None) -> Inspection: ... + + +@runtime_checkable +class Probeable(Resolver, Protocol): + def probe(self, res: Resolution, *, effort: Effort, deadline_s: float = 3.0) -> Probe: ... diff --git a/hermes_platform/resolver/core.py b/hermes_platform/resolver/core.py new file mode 100644 index 0000000000..8b27e84a54 --- /dev/null +++ b/hermes_platform/resolver/core.py @@ -0,0 +1,148 @@ +"""Result and context types shared by every resolver, plus passive command location. + +`locate_command` reads only file metadata. It never opens a file, spawns, or connects. +""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass, field +from enum import Enum +from typing import Generic, Literal, TypeVar + +Kind = Literal["explicit_path", "path_executable", "known_path", "package_runner", "missing"] + +T = TypeVar("T") + + +class _Absent: + __slots__ = () + + def __repr__(self) -> str: + return "ABSENT" + + +ABSENT = _Absent() + + +class CheckState(Enum): + NOT_CHECKED = "not_checked" + PRESENT = "present" + ABSENT = "absent" + UNAVAILABLE = "unavailable" + ERROR = "error" + + +@dataclass(frozen=True) +class Observation(Generic[T]): + """One checked fact. Consumers branch on `state`; `detail` is never parsed.""" + + state: CheckState + value: T | None = None + detail: str = "" + + @classmethod + def not_checked(cls) -> "Observation[T]": + return cls(CheckState.NOT_CHECKED) + + +@dataclass(frozen=True) +class Candidate: + value: str + source: str + present: bool + + +@dataclass(frozen=True) +class LookupContext: + """`path`: ABSENT = caller did not say (ambient), None = ambient, "" = search nothing.""" + + path: str | None | _Absent = ABSENT + pathext: str | None = None + + def effective_path(self) -> str | None: + if isinstance(self.path, str): + return self.path + return None + + +@dataclass(frozen=True) +class Resolution: + kind: Kind + candidates: tuple[Candidate, ...] = field(default_factory=tuple) + + @property + def command(self) -> tuple[str, ...]: + first = next((c for c in self.candidates if c.present), None) + return (first.value,) if first else () + + @property + def source(self) -> str: + first = next((c for c in self.candidates if c.present), None) + return first.source if first else "none" + + @property + def present(self) -> tuple[Candidate, ...]: + return tuple(c for c in self.candidates if c.present) + + @property + def found(self) -> bool: + return self.kind != "missing" + + +def _which(name: str, ctx: LookupContext) -> str | None: + if ctx.path == "": + return None + if ctx.pathext is not None and os.name == "nt": + # shutil.which reads PATHEXT from os.environ; an explicit pathext is honored without mutating it. + exts = [e for e in ctx.pathext.split(os.pathsep) if e] + for ext in ["", *exts]: + hit = shutil.which(name + ext, path=ctx.effective_path()) + if hit: + return hit + return None + return shutil.which(name, path=ctx.effective_path()) + + +def _is_executable_file(path: str) -> bool: + return os.path.isfile(path) and os.access(path, os.X_OK) + + +def _expand(path: str) -> str: + return os.path.expandvars(os.path.expanduser(path)) + + +def locate_command(name: str, ctx: LookupContext | None = None, *, known_dirs: tuple[str, ...] = ()) -> Resolution: + """Find `name` on PATH, then in `known_dirs`, recording every candidate in probe order.""" + ctx = ctx or LookupContext() + name = name.strip() + if os.sep in name or (os.altsep and os.altsep in name): + expanded = _expand(name) + # A relative explicit path would resolve against the ambient cwd; only absolute paths are trusted. + present = os.path.isabs(expanded) and _is_executable_file(expanded) + cand = Candidate(expanded, "explicit", present) + return Resolution("explicit_path" if present else "missing", (cand,)) + + candidates: list[Candidate] = [] + hit = _which(name, ctx) + candidates.append(Candidate(hit or name, "PATH", hit is not None)) + for d in known_dirs: + base = os.path.join(_expand(d), name) + found = next((v for v in _known_dir_variants(base) if _is_executable_file(v)), None) + candidates.append(Candidate(found or base, f"known_dir:{d}", found is not None)) + + kind: Kind = "missing" + for c in candidates: + if c.present: + kind = "path_executable" if c.source == "PATH" else "known_path" + break + return Resolution(kind, tuple(candidates)) + + +def _known_dir_variants(base: str) -> tuple[str, ...]: + """On Windows a bare name in a known dir may carry any PATHEXT suffix; one candidate is recorded per dir.""" + if os.name != "nt": + return (base,) + exts = [e for e in os.environ.get("PATHEXT", ".EXE;.CMD;.BAT").split(os.pathsep) if e] + return (base, *[base + e for e in exts]) diff --git a/hermes_platform/resolver/known_dirs.py b/hermes_platform/resolver/known_dirs.py new file mode 100644 index 0000000000..d22fe0ac13 --- /dev/null +++ b/hermes_platform/resolver/known_dirs.py @@ -0,0 +1,43 @@ +"""Directories where tools install outside PATH, grouped by the ecosystem that owns them. + +Every table in Hermes lives here. A directory literal outside this module fails the ratchet in +`tests/test_managed_runtime_resolution.py`. Each table is empty on an OS where the ecosystem +does not install there, so callers compose tables without OS branches. +""" + +from __future__ import annotations + +import sys + +_POSIX = sys.platform != "win32" +_WIN = sys.platform == "win32" + + +def homebrew_dirs() -> tuple[str, ...]: + return ("/opt/homebrew/bin", "/usr/local/bin") if sys.platform == "darwin" else () + + +def user_local_bin() -> tuple[str, ...]: + return ("~/.local/bin",) if _POSIX else ("%USERPROFILE%/.local/bin",) + + +def rust_tool_dirs() -> tuple[str, ...]: + return ("~/.cargo/bin",) if _POSIX else ("%USERPROFILE%/.cargo/bin",) + + +def node_tool_dirs() -> tuple[str, ...]: + return ("~/.npm-global/bin", "~/.bun/bin", "~/.volta/bin") if _POSIX else ("%APPDATA%/npm", "%USERPROFILE%/.bun/bin", "%LOCALAPPDATA%/Volta/bin") + + +def hermes_vendored_dirs() -> tuple[str, ...]: + return ("~/.hermes/bin",) if _POSIX else ("%USERPROFILE%/.hermes/bin",) + + +def windows_user_program_dirs() -> tuple[str, ...]: + if not _WIN: + return () + return ( + "%LOCALAPPDATA%/Programs", + "%USERPROFILE%/scoop/shims", + "%LOCALAPPDATA%/Microsoft/WinGet/Links", + ) diff --git a/pyproject.toml b/pyproject.toml index feb61e9480..55caa670b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -609,8 +609,6 @@ youtube-transcript-api = false include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*", "hermes_platform", "hermes_platform.*"] [tool.setuptools.package-data] -# hermes_platform/catalog ships apps.yaml (PR2); declared now so the wheel picks it up on arrival. -hermes_platform = ["catalog/*.yaml"] hermes_cli = ["observability/schemas/*.json", "data/*.json", "local_runtime/*.json"] # gateway/assets/ ships status_phrases.yaml and the Telegram BotFather # screenshot. Without this, sealed venvs (uv2nix) silently lose both — diff --git a/tests/agent/test_skill_app_snapshot.py b/tests/agent/test_skill_app_snapshot.py new file mode 100644 index 0000000000..c72a4a47f8 --- /dev/null +++ b/tests/agent/test_skill_app_snapshot.py @@ -0,0 +1,42 @@ +"""Application requirements survive disk and in-process skill-index reuse.""" + +import sys + +from hermes_platform import declaration + + +def test_application_gate_rechecks_snapshot_without_losing_description(tmp_path, monkeypatch): + from agent import prompt_builder as pb + + monkeypatch.setattr(declaration, "_REGISTRY", {}) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(pb, "get_disabled_skill_names", lambda *_: set()) + skills = tmp_path / "skills" + skill = skills / "app-guide" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text( + "---\nname: app-guide\ndescription: Application instructions.\n" + "requires_apps: [thing]\n---\nHelp with the application.\n", encoding="utf-8", + ) + app = tmp_path / "application.exe" + declaration.register("thing", declaration.parse_declaration( + "thing", {sys.platform: {"presence": "executable", "location": str(app)}}, + {"app": True}, where="test-plugin/plugin.yaml", + )) + pb.clear_skills_system_prompt_cache(clear_snapshot=True) + try: + def build(): + return pb._build_skills_system_prompt_inner(skills, [], None, None, None) + + assert "app-guide" not in build() + snapshot = pb._load_skills_snapshot(skills) + assert snapshot is not None + assert snapshot["skills"][0]["requires_apps"] == ["thing"] + app.write_text("presence fixture", encoding="utf-8") + assert "app-guide: Application instructions." in build() + app.unlink() + assert "app-guide" not in build() + pb.clear_skills_system_prompt_cache() + assert "app-guide" not in build() + finally: + pb.clear_skills_system_prompt_cache(clear_snapshot=True) diff --git a/tests/fixtures/resolution_allowlist.json b/tests/fixtures/resolution_allowlist.json index 0403a012df..250e66da05 100644 --- a/tests/fixtures/resolution_allowlist.json +++ b/tests/fixtures/resolution_allowlist.json @@ -144,11 +144,6 @@ "symbol": "edit_config", "kind": "bare_which" }, - { - "path": "hermes_cli/copilot_auth.py", - "symbol": "_gh_cli_candidates", - "kind": "bare_which" - }, { "path": "hermes_cli/dep_ensure.py", "symbol": "", @@ -704,11 +699,6 @@ "symbol": "_find_uv_binary", "kind": "known_path_table" }, - { - "path": "hermes_cli/copilot_auth.py", - "symbol": "_gh_cli_candidates", - "kind": "known_path_table" - }, { "path": "tools/computer_use/cua_backend_driver.py", "symbol": "_candidate_cua_driver_commands", diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 480bf0681c..70043cd8f6 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -358,19 +358,19 @@ class TestResolveApiKeyProviderCredentials: - def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch): + def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch, tmp_path): from hermes_cli.copilot_auth import _invalidate_gh_cli_token_cache + from hermes_platform.resolver import known_dirs _invalidate_gh_cli_token_cache() - monkeypatch.setattr("hermes_cli.copilot_auth.shutil.which", lambda command: None) - monkeypatch.setattr( - "hermes_cli.copilot_auth.os.path.isfile", - lambda path: path == "/opt/homebrew/bin/gh", - ) - monkeypatch.setattr( - "hermes_cli.copilot_auth.os.access", - lambda path, mode: path == "/opt/homebrew/bin/gh" and mode == os.X_OK, - ) + brew = tmp_path / "homebrew" / "bin" + brew.mkdir(parents=True) + gh = brew / "gh" + gh.write_text("#!/bin/sh\n", encoding="utf-8") + gh.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + monkeypatch.setattr(known_dirs, "homebrew_dirs", lambda: (str(brew),)) + monkeypatch.setattr(known_dirs, "user_local_bin", lambda: ()) calls = [] @@ -385,7 +385,7 @@ class TestResolveApiKeyProviderCredentials: monkeypatch.setattr("hermes_cli.copilot_auth.subprocess.run", _fake_run) assert _try_gh_cli_token() == "gh-cli-secret" - assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]] + assert calls == [[str(gh), "auth", "token"]] diff --git a/tests/hermes_platform/test_declaration.py b/tests/hermes_platform/test_declaration.py new file mode 100644 index 0000000000..26084a21a5 --- /dev/null +++ b/tests/hermes_platform/test_declaration.py @@ -0,0 +1,172 @@ +"""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, 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"), +]) +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 + + +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.macos_only +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_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", 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() diff --git a/tests/hermes_platform/test_import_hygiene.py b/tests/hermes_platform/test_import_hygiene.py index 50160c96ec..3cbf732f71 100644 --- a/tests/hermes_platform/test_import_hygiene.py +++ b/tests/hermes_platform/test_import_hygiene.py @@ -6,7 +6,7 @@ import subprocess import sys -def test_host_modules_only_import_stdlib_and_hermes_platform() -> None: +def test_platform_modules_only_import_stdlib_and_hermes_platform() -> None: root = Path(__file__).resolve().parents[2] code = """ import sys @@ -14,6 +14,11 @@ before = set(sys.modules) import hermes_platform.host.facts import hermes_platform.host.runtime import hermes_platform.host.products +import hermes_platform.declaration +import hermes_platform.resolver +import hermes_platform.resolver.app +import hermes_platform.resolver.availability +import hermes_platform.resolver.known_dirs new_top_levels = {name.partition('.')[0] for name in set(sys.modules) - before} unexpected = sorted( name diff --git a/tests/hermes_platform/test_resolver_app.py b/tests/hermes_platform/test_resolver_app.py new file mode 100644 index 0000000000..fca388a5d7 --- /dev/null +++ b/tests/hermes_platform/test_resolver_app.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import dataclasses +import json +import os +import plistlib +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from hermes_platform.resolver import CheckState, Effort, Probeable, Resolver +from hermes_platform.resolver.app import AppDef, AppResolver + +TOKEN = "tok-3e1f9c-unique-fixture-value" + + +def _bundle(tmp_path, version="9.8.7"): + app = tmp_path / "Applications" / "Thing.app" + (app / "Contents").mkdir(parents=True) + with open(app / "Contents" / "Info.plist", "wb") as fh: + plistlib.dump({"CFBundleShortVersionString": version}, fh) + return app + + +def _server_json(tmp_path, *, url, pid=None): + p = tmp_path / "server.json" + p.write_text(json.dumps({"pid": os.getpid() if pid is None else pid, "http": url, "token": TOKEN}), encoding="utf-8") + return p + + +def _resolver(tmp_path, exe, server_json=None): + return AppResolver(AppDef( + "thing", sys.platform, "executable", str(exe), + liveness_kind="server_json" if server_json else "none", + liveness_path=str(server_json) if server_json else "", + )) + + +def test_app_resolver_satisfies_both_protocols(tmp_path): + r = AppResolver(AppDef("x", sys.platform, "executable", str(tmp_path / "x"))) + assert isinstance(r, Resolver) and isinstance(r, Probeable) + + +def test_locate_reports_expanded_path_when_missing(tmp_path, monkeypatch): + monkeypatch.setenv("THINGROOT", str(tmp_path)) + r = AppResolver(AppDef("thing", sys.platform, "executable", "$THINGROOT/bin/thing")) + res = r.locate() + assert res.kind == "missing" + assert res.candidates[0].value == str(tmp_path / "bin" / "thing") + + +def test_bundle_presence_and_plist_version(tmp_path): + app = _bundle(tmp_path) + r = AppResolver(AppDef("thing", "darwin", "bundle", str(app), version_kind="plist")) + res = r.locate() + assert res.kind == "known_path" and res.command == (str(app),) + insp = r.inspect(res) + assert insp.version.state is CheckState.PRESENT and insp.version.value == "9.8.7" + assert insp.signer.state is CheckState.NOT_CHECKED + + +def test_inspect_on_missing_is_not_checked(tmp_path): + r = AppResolver(AppDef("thing", sys.platform, "executable", str(tmp_path / "none"), version_kind="plist")) + insp = r.inspect(r.locate()) + assert insp.version.state is CheckState.NOT_CHECKED + + +def test_probe_without_liveness_source_is_all_not_checked(tmp_path): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + r = _resolver(tmp_path, exe) + pr = r.probe(r.locate(), effort=Effort.NETWORK) + assert pr.running.state is pr.answering.state is pr.endpoint.state is CheckState.NOT_CHECKED + + +def test_probe_local_reads_pid_and_endpoint_but_never_connects(tmp_path): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url="http://127.0.0.1:1/mcp") + r = _resolver(tmp_path, exe, sj) + pr = r.probe(r.locate(), effort=Effort.LOCAL) + assert pr.running.state is CheckState.PRESENT and pr.running.value is True + assert pr.endpoint.value == "http://127.0.0.1:1/mcp" + assert pr.answering.state is CheckState.NOT_CHECKED + + +def test_probe_is_not_a_boolean(tmp_path): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + pr = _resolver(tmp_path, exe).probe(_resolver(tmp_path, exe).locate(), effort=Effort.LOCAL) + with pytest.raises(TypeError): + bool(pr) + + +def test_token_never_appears_in_any_public_result(tmp_path): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url="http://127.0.0.1:1/mcp") + r = _resolver(tmp_path, exe, sj) + res = r.locate() + pr = r.probe(res, effort=Effort.LOCAL) + for obj in (res, r.inspect(res), pr): + assert TOKEN not in repr(obj) + assert TOKEN not in json.dumps(dataclasses.asdict(obj), default=str) + assert "token" not in {f.name for f in dataclasses.fields(obj)} + + +@pytest.mark.parametrize("url", [ + "http://[::1", + "https://127.0.0.1:1234/mcp", + "http://user:pw@127.0.0.1:1234/mcp", + "http://example.com:1234/mcp", + "http://127.0.0.1/mcp", + "http://127.0.0.1:99999/mcp", +]) +def test_non_loopback_or_malformed_endpoint_is_unavailable_and_never_contacted(tmp_path, url, monkeypatch): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url=url) + r = _resolver(tmp_path, exe, sj) + monkeypatch.setattr(socket, "create_connection", lambda *a, **k: pytest.fail("no socket allowed")) + pr = r.probe(r.locate(), effort=Effort.NETWORK) + assert pr.endpoint.state is CheckState.UNAVAILABLE + assert pr.answering.state is CheckState.NOT_CHECKED + + +def test_dead_pid_is_absent_and_skips_network(tmp_path, monkeypatch): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url="http://127.0.0.1:1/mcp", pid=2**22 + 12345) + r = _resolver(tmp_path, exe, sj) + monkeypatch.setattr(socket, "create_connection", lambda *a, **k: pytest.fail("no socket allowed")) + pr = r.probe(r.locate(), effort=Effort.NETWORK) + assert pr.running.state is CheckState.ABSENT + assert pr.answering.state is CheckState.NOT_CHECKED + + +class _Handler(BaseHTTPRequestHandler): + seen: list[tuple[str, str]] = [] + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + _Handler.seen.append((self.path, self.headers.get("Authorization", ""))) + self.send_response(200 if body.get("method") == "initialize" else 400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"jsonrpc":"2.0","id":1,"result":{}}') + + def log_message(self, format, *args): # noqa: A002 + pass + + +@pytest.fixture +def loopback_mcp(): + srv = HTTPServer(("127.0.0.1", 0), _Handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + _Handler.seen.clear() + yield srv.server_address[1] + srv.shutdown() + + +def test_network_probe_reads_server_json_every_call_and_answers(tmp_path, loopback_mcp): + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url=f"http://127.0.0.1:{loopback_mcp}/ignored") + r = _resolver(tmp_path, exe, sj) + res = r.locate() + first = r.probe(res, effort=Effort.NETWORK) + assert first.answering.state is CheckState.PRESENT and first.answering.value is True + assert _Handler.seen[-1] == ("/mcp", f"Bearer {TOKEN}") + # the vendor moves the port: the next probe must follow without any caller action + sj.write_text(json.dumps({"pid": os.getpid(), "http": "http://127.0.0.1:1/x", "token": TOKEN}), encoding="utf-8") + second = r.probe(res, effort=Effort.NETWORK) + assert second.endpoint.value == "http://127.0.0.1:1/mcp" + assert second.answering.state is CheckState.ABSENT + + +class _SlowDrip(BaseHTTPRequestHandler): + """Fast 200 and headers, then one body byte every 0.3 s: each receive is under any per-socket timeout.""" + + def do_POST(self): + import time as _t + self.send_response(200) + self.send_header("Content-Length", "64") + self.end_headers() + for _ in range(64): + try: + self.wfile.write(b"x") + self.wfile.flush() + except OSError: + return + _t.sleep(0.3) + + def log_message(self, format, *args): # noqa: A002 + pass + + +def test_network_probe_honors_one_absolute_deadline(tmp_path): + import time as _t + srv = HTTPServer(("127.0.0.1", 0), _SlowDrip) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + exe = tmp_path / "thing" + exe.write_text("", encoding="utf-8") + sj = _server_json(tmp_path, url=f"http://127.0.0.1:{srv.server_address[1]}/x") + r = _resolver(tmp_path, exe, sj) + t0 = _t.monotonic() + pr = r.probe(r.locate(), effort=Effort.NETWORK, deadline_s=0.5) + elapsed = _t.monotonic() - t0 + assert elapsed < 2.0, elapsed + assert pr.answering.state is CheckState.PRESENT + finally: + srv.shutdown() diff --git a/tests/hermes_platform/test_resolver_core.py b/tests/hermes_platform/test_resolver_core.py new file mode 100644 index 0000000000..1049945e67 --- /dev/null +++ b/tests/hermes_platform/test_resolver_core.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os +import stat +import sys + +import pytest + +from hermes_platform.resolver import ABSENT, CheckState, LookupContext, Observation, locate_command + + +def _make_exe(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n", encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + return path + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable bits") +def test_path_hit_comes_first_and_known_dirs_are_still_recorded(tmp_path): + on_path = _make_exe(tmp_path / "pathbin" / "tool") + in_known = _make_exe(tmp_path / "known" / "tool") + res = locate_command("tool", LookupContext(path=str(on_path.parent)), known_dirs=(str(in_known.parent),)) + assert res.kind == "path_executable" + assert res.command == (str(on_path),) + assert [c.present for c in res.candidates] == [True, True] + assert [c.value for c in res.present] == [str(on_path), str(in_known)] + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable bits") +def test_known_dir_hit_when_path_misses(tmp_path): + in_known = _make_exe(tmp_path / "known" / "tool") + res = locate_command("tool", LookupContext(path=""), known_dirs=(str(in_known.parent),)) + assert res.kind == "known_path" + assert res.source == f"known_dir:{in_known.parent}" + assert res.candidates[0].present is False + + +def test_empty_path_is_a_miss_not_ambient(tmp_path): + res = locate_command("python3", LookupContext(path="")) + assert res.kind == "missing" + assert res.command == () + assert res.present == () + + +def test_absent_and_none_both_mean_ambient(): + assert LookupContext().path is ABSENT + assert LookupContext().effective_path() is None + assert LookupContext(path=None).effective_path() is None + assert LookupContext(path="/x").effective_path() == "/x" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable bits") +def test_explicit_path_bypasses_search(tmp_path): + exe = _make_exe(tmp_path / "bin" / "tool") + res = locate_command(str(exe), LookupContext(path="")) + assert res.kind == "explicit_path" + assert res.command == (str(exe),) + missing = locate_command(str(tmp_path / "nope" / "tool"), LookupContext(path="")) + assert missing.kind == "missing" and missing.candidates[0].source == "explicit" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable bits") +def test_locate_never_searches_the_working_directory(tmp_path, monkeypatch): + _make_exe(tmp_path / "tool") + monkeypatch.chdir(tmp_path) + assert locate_command("tool", LookupContext(path="")).kind == "missing" + relative = locate_command("./tool", LookupContext(path="")) + assert relative.kind == "missing" and relative.candidates[0].present is False + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable bits") +def test_known_dir_expands_home_and_env(tmp_path, monkeypatch): + exe = _make_exe(tmp_path / "home" / ".local" / "bin" / "tool") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("TOOLROOT", str(tmp_path / "home" / ".local")) + for spec in ("~/.local/bin", "$TOOLROOT/bin"): + res = locate_command("tool", LookupContext(path=""), known_dirs=(spec,)) + assert res.command == (str(exe),), spec + + +@pytest.mark.windows_only +def test_windows_pathext_is_honored_without_mutating_environ(tmp_path, monkeypatch): + exe = tmp_path / "bin" / "tool.cmd" + exe.parent.mkdir() + exe.write_text("@echo off\r\n", encoding="utf-8") + before = os.environ.get("PATHEXT") + res = locate_command("tool", LookupContext(path=str(exe.parent), pathext=".CMD")) + assert res.kind == "path_executable" + assert res.command[0].lower() == str(exe).lower() + assert os.environ.get("PATHEXT") == before + + +def test_observation_not_checked_is_distinct_from_absent(): + assert Observation.not_checked().state is CheckState.NOT_CHECKED + assert Observation(CheckState.ABSENT, False).state is not CheckState.NOT_CHECKED + assert Observation(CheckState.ABSENT, False).value is False + + +def test_one_candidate_per_known_dir_regardless_of_pathext(tmp_path): + res = locate_command("nothing-here", LookupContext(path=""), known_dirs=(str(tmp_path / "a"), str(tmp_path / "b"))) + assert [c.source for c in res.candidates] == ["PATH", f"known_dir:{tmp_path / 'a'}", f"known_dir:{tmp_path / 'b'}"] + + +def test_known_dir_tables_match_the_host_os(): + from hermes_platform.resolver import known_dirs as kd + + every = (*kd.homebrew_dirs(), *kd.user_local_bin(), *kd.rust_tool_dirs(), + *kd.node_tool_dirs(), *kd.hermes_vendored_dirs(), *kd.windows_user_program_dirs()) + assert every, "at least one table applies on every host" + if sys.platform == "win32": + assert not any(d.startswith("/") for d in every) + else: + assert not any("%" in d for d in every) + assert bool(kd.homebrew_dirs()) == (sys.platform == "darwin") diff --git a/tests/tools/test_mcp_app_gate.py b/tests/tools/test_mcp_app_gate.py new file mode 100644 index 0000000000..b55d82fc83 --- /dev/null +++ b/tests/tools/test_mcp_app_gate.py @@ -0,0 +1,48 @@ +"""Application requirements compose with live, recycled, and lazy MCP connections.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest + +from hermes_platform import declaration + + +@pytest.mark.parametrize("connection", ["live", "recycled", "lazy", "absent"]) +def test_check_fn_composes_connection_and_registered_requirement(tmp_path, monkeypatch, connection): + from tools import mcp_tool, mcp_tool_handlers + from tools.mcp_tool_scope import _resolve_server_key + + monkeypatch.setattr(declaration, "_REGISTRY", {}) + core = mcp_tool + monkeypatch.setattr(core, "_servers", {}) + monkeypatch.setattr(core, "_lazy_server_configs", {}) + monkeypatch.setattr(core, "_server_tool_scopes", {}) + name = "declaration-test-server" + key = _resolve_server_key(name) + if connection == "lazy": + core._lazy_server_configs[key] = {} + elif connection != "absent": + core._servers[key] = SimpleNamespace( + session=object() if connection == "live" else None, + _is_recycled_stdio=lambda: connection == "recycled", + ) + check = mcp_tool_handlers._make_check_fn(name) + connected = connection != "absent" + assert check() is connected + + location = tmp_path / "application.exe" + decl = declaration.parse_declaration( + name, {sys.platform: {"presence": "executable", "location": str(location)}}, + {"app": True}, where="test-plugin/plugin.yaml", + ) + declaration.register(name, decl) + assert check() is False + location.write_text("presence fixture", encoding="utf-8") + assert check() is connected + location.unlink() + assert check() is False + declaration.clear() + assert check() is connected diff --git a/tools/mcp_tool_handlers.py b/tools/mcp_tool_handlers.py index b94122d62f..fb7b724bc2 100644 --- a/tools/mcp_tool_handlers.py +++ b/tools/mcp_tool_handlers.py @@ -11,7 +11,9 @@ from contextlib import asynccontextmanager from functools import partial from types import SimpleNamespace from typing import Any, Callable, Dict, List, Optional, Tuple -from tools.registry import tool_error + +from hermes_platform import declaration +from tools.registry import invalidate_check_fn_cache, tool_error from tools.ansi_strip import strip_unicode_tags from tools.mcp_tool_common import _exc_str, _sanitize_error, mcp_field, _core from tools import mcp_tool_loop as _loop @@ -24,6 +26,8 @@ from tools.mcp_tool_errors import _is_auth_error, _is_session_expired_error logger = logging.getLogger("tools.mcp_tool") _MISSING = object() +declaration.on_change = invalidate_check_fn_cache + _NEEDS_REAUTH_MSG = ( "MCP server '{s}' requires re-authentication. Run `hermes mcp login {s}` (or delete the tokens file under " "~/.hermes/mcp-tokens/ and restart). Do NOT retry this tool — ask the user to re-authenticate.") @@ -667,13 +671,35 @@ _make_get_prompt_handler = _make_utility_handler( def _make_check_fn(server_name: str): - """Connection-alive check; lazy (schema-cache registered) servers count as available.""" + """Connection-alive check; lazy (schema-cache registered) servers count as available. + + When the server's owner registered an application declaration (`requires.app`), the + application must also be present on this host, or the tools are not offered even while a + stale connection lingers. With no declaration registered the check is the connection check + alone. Returns a plain bool: the registry caches ``bool(fn())``. + """ from tools.mcp_tool_scope import _resolve_server_key - def _check() -> bool: + def _connected() -> bool: with _core._lock: key = _resolve_server_key(server_name) server = _core._servers.get(key) return ((server is not None and (server.session is not None or server._is_recycled_stdio())) or key in _core._lazy_server_configs) + + def _check() -> bool: + if not _connected(): + return False + return _declared_app_offerable(server_name) return _check + + +def _declared_app_offerable(server_name: str) -> bool: + """True unless a declaration registered for this server requires an application this host lacks.""" + from hermes_platform import declaration + from hermes_platform.resolver.availability import availability + + decl = declaration.lookup(server_name) + if decl is None or not decl.requires_app: + return True + return availability(decl).offerable diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 456bc1ea8c..b1e60f5d22 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -114,6 +114,7 @@ def _skill_utils_delegate(attr: str): skill_matches_platform = _skill_utils_delegate("skill_matches_platform") # Offer-time relevance gate (kanban/docker/s6), NOT hard compatibility; explicit loads bypass it. skill_matches_environment = _skill_utils_delegate("skill_matches_environment") +skill_matches_apps = _skill_utils_delegate("skill_matches_apps") _parse_frontmatter = _skill_utils_delegate("parse_frontmatter") _get_disabled_skill_names = _skill_utils_delegate("get_disabled_skill_names") @@ -203,7 +204,7 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: continue try: frontmatter, body = _parse_frontmatter(_read_skill_text(skill_md)[:4000]) - if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter): + if not skill_matches_platform(frontmatter) or not skill_matches_environment(frontmatter) or not skill_matches_apps(frontmatter): continue name = frontmatter.get("name", skill_md.parent.name)[:MAX_NAME_LENGTH] if name in seen_names or name in disabled: diff --git a/website/docs/developer-guide/plugins/application-declarations.md b/website/docs/developer-guide/plugins/application-declarations.md new file mode 100644 index 0000000000..20c465be6e --- /dev/null +++ b/website/docs/developer-guide/plugins/application-declarations.md @@ -0,0 +1,99 @@ +# Application declarations + +A plugin whose MCP server fronts a desktop application declares which application that is and what the server needs of it. The core evaluates the declaration on the host and gates the server's tools, and any skill that names the application, on the answer. The parser imports only the standard library and `hermes_platform`. + +The vocabulary lives in `hermes_platform/declaration.py`. A declaration is data plus policy, parsed from plain mappings (already-decoded YAML, JSON, a dict literal — the parser never touches a file): + +```python +from hermes_platform import declaration + +decl = declaration.parse_declaration( + "my-server", + raw_app={"linux": {"presence": "executable", "location": "/opt/my-app/server"}}, + raw_requires={"app": True}, + where="my-plugin/plugin.yaml", # human label used in error messages +) +declaration.register("my-server", decl) +``` + +`register(server_name, decl)` stores one declaration under the configured server name in a process-local registry. Loader integration is separate work; core does not read plugin YAML automatically. + +An unregistered MCP server keeps its connection-only check. A skill that explicitly names an unregistered server is hidden. `clear()` removes every registration and is not a per-plugin unload operation. Registrations are process-wide, not profile-scoped. + +## `app` — how to find the application on each OS + +```yaml +app: + win32: + presence: executable + location: "%ProgramFiles%/Vendor/Vendor App/McpServer/Server.exe" + version: { kind: uninstall_registry, display_name_prefix: "Vendor App" } + liveness: + kind: server_json + path: "%LOCALAPPDATA%/Vendor/Vendor App/McpServer/server.json" + pid_key: pid + url_key: http + token_key: token + endpoint_path: /mcp + darwin: + presence: bundle + location: /Applications/Vendor.app + version: { kind: plist } +``` + +| field | type | rule | maps to `AppDef` | +|---|---|---|---| +| `` | `win32` \| `darwin` \| `linux` | at least one; unknown key is an error | `AppDef.os_family` | +| `presence` | `executable` \| `bundle` | required per OS | `.presence` | +| `location` | str | required; drive-rooted (`C:\\...`) on Windows, or starting with `~` / `%VAR%` / `$VAR`; UNC paths are rejected so a presence check never touches the network; no `..` segment or URL scheme; expansion at lookup | `.location` | +| `version.kind` | `pe_resource` \| `plist` \| `uninstall_registry` \| `none` | default `none`; `pe_resource`/`uninstall_registry` only under `win32`, `plist` only under `darwin` | `.version_kind` | +| `version.display_name_prefix` | str | required when `uninstall_registry` | `.version_arg` | +| `liveness.kind` | `server_json` \| `none` | default `none` | `.liveness_kind` | +| `liveness.path` | str | required when `server_json` | `.liveness_path` | +| `liveness.pid_key` / `url_key` / `token_key` | str | defaults `pid` / `http` / `token` | `.liveness_*_key` | +| `liveness.endpoint_path` | str | default `/mcp`; the path used for `initialize`, never the one in the file | `.endpoint_path` | + +When `requires.app` is true, an OS missing from `app:` gives `unsupported_os`. + +## `requires` — what the server needs before it is offered + +```yaml +requires: + app: true + min_version: "2.3.0" +``` + +| field | type | rule | +|---|---|---| +| `app` | bool | when true, `app:` must exist and the server is gated on presence | +| `min_version` | str | requires `app: true`; dotted numeric; every applicable `app.` must declare a real `version.kind`; compared numerically per segment, non-numeric characters in a segment are dropped (`2.3.0.12594` ≥ `2.3.0`; prerelease suffixes are not ordered) | + +`requires.app: true` with no `app:` block is a `DeclarationError`. + +## Availability: the one evaluation every reader uses + +`hermes_platform/resolver/availability.py::availability(decl) -> Availability` + +``` +Availability( + state: available | installed_not_running | missing_app | version_too_old + | unsupported_os | no_requirements, + version: str | None, # inspected, when present + path: str | None, # where the app was found or looked for + min_version: str | None, # from requires +) +``` + +- `no_requirements`: no `requires.app`; the application gate passes, but the connection check still applies. +- `unsupported_os`: `requires.app` and no `app.` block. Zero I/O. +- `missing_app`: `locate` found nothing at `location`. +- `version_too_old`: the version is below the minimum or cannot be read. +- `available`: present, version acceptable or not required. +- `installed_not_running`: reserved vocabulary; this evaluator never produces it. + +Evaluation uses `locate` and optional version inspection. It never probes a server, launches an application, or connects. The tool registry retains its existing availability cache. + +## The two gates + +- **MCP `check_fn`** (`tools/mcp_tool_handlers.py::_make_check_fn`): connection alive AND, when a declaration with `requires.app` is registered for the server, `availability(decl).offerable`. Returns a plain `bool` because the registry caches `bool(fn())`. +- **Skill `requires_apps:` frontmatter** (`agent/skill_utils.py::skill_matches_apps`): each name resolves through `declaration.lookup`; an unknown name hides the skill (fail closed). Offer-time filter, like `environments:`. diff --git a/website/sidebars.ts b/website/sidebars.ts index 548d5ecf07..3021b2abf4 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -829,6 +829,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/web-search-provider-plugin', 'developer-guide/browser-provider-plugin', 'developer-guide/terminal-environment-plugin', + 'developer-guide/plugins/application-declarations', ], }, 'developer-guide/creating-skills', From dc50403a81d19fc2cad8839bee6a8695b68711c0 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:16:00 +0530 Subject: [PATCH 21/35] feat(desktop): the Connectors page replaces the MCP tab (#119074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connectors): the backend serves a connector's tool list, cached for 24 hours The Connectors page opens one app and shows every tool it has. The backend had no way to read that list. - `tools/connectors/portal/`: a client for the portal's tool-list route and a JSON cache under the Hermes home, one file per portal origin and connector. An entry is fresh for 24 hours. After that the read revalidates with the stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure serves the stored list marked stale, and a 401 never serves the cache. - `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no chat session. Errors carry a fixed `reason` from one closed set on the rail. - Every connector model that is not operation state moves into `tui_gateway/contracts/connectors.py`. Handlers that no chat session owns live in `tui_gateway/methods_connectors_account.py`. The wire model is tolerant: an unknown facet reads as unclassified and one odd tool never blanks a connector. * feat(connectors): catalog, accounts and member tool rules by RPC The Connectors page needs the app catalog, the connected account of one app, a way to disconnect it, and the member's own on/off rules. None had an RPC. - `connectors.catalog`: name, description, category and logo of each app. - `connectors.accounts`, `connectors.accounts.remove`: read the accounts at the tool gateway and remove one by id. - `connectors.policy.get`: the rule layers that apply to the member, widest first. The body is a union on `mode`, so a reader can name who turned a tool off. - `connectors.policy.set`: one change, a union on `type` (the tools of one connector, or one connector on or off), with the revision the user saw. A stale revision answers `POLICY_CONFLICT`. The backend composes the upstream write in one pure function, so no renderer learns the upstream rules. - Bundled MCP manifests can name their hosted twin with `connector:`, so the page can show one card per app. * feat(connectors): connect an app without a chat session Every connector RPC took a `session_id`, and a connect that did not come from the model's tool call minted a link with no watcher. The Connectors page has no chat session, and its card must flip to connected by itself. - `connectors.list`, `connectors.connect`, `connectors.operation.status`, `connectors.operation.wake` and `connection.respond` take `owner`, a union on `type`: `session` (today's behaviour and authorization) or `account` (routed by `profile`, authorized by the live transport like `mcp.*`). `session_id` is gone from these params; every desktop caller sends `owner`. - An account connect runs the same operation lifecycle on a background thread, under the profile's scope, so the watcher reads the account and settles the operation. A second connect for an app that is already connecting returns the open operation and mints nothing. - `connection.update` carries `owner`. An account operation has no session to address, so its updates go out on the session-less broadcast path. * feat(mcp-catalog): eighteen more bundled entries name their hosted connector A bundled MCP entry and a hosted connector for the same app are one card on the Connectors page only when the manifest names its hosted twin. Linear and Notion had the field. These entries get it too: airtable, asana, attio, calendly, dropbox, figma, railway, supabase, todoist, betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe and vercel. Atlassian maps to two hosted connectors and Prisma Postgres is not clearly the same app, so both stay without one. * refactor(connectors): the account handlers share one gate, one params model and one write table The six account-level handlers each repeated the availability gate, the auth catch and the catch-all reply. One decorator now owns that, and each handler validates its params with its contract model instead of a ladder of isinstance checks. The five connection RPCs share one guard for the unexpected-failure reply. The four write composers for the member rules were the same function with a different list key and polarity. They are one table now. The owner union lives in contracts/common.py, so the params side and the event side stop declaring it twice and the import cycle is gone. An account operation start carries one event and a flag, so the wait for the sign-in link blocks instead of polling every 50 ms. run_operation loses its two account-only parameters; drive_operation is the second entry point. Tests: four deleted (they exercised pydantic or the mock), three merged into tables, two added (a client that still sends the old top-level session_id is refused; all six account RPCs run off the server loop). The shared reply helper and the HTTP and managed-client fakes move to one place each. Comments are one line or gone. * fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone" The tool-list read treated every 404 as the portal's "this connector is not in the catalog" answer. It deleted the cache entry and answered CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is connected and works. A portal that does not serve the route yet answers a bare 404 for every app. Only the portal's own {"error": "connector_not_found"} means the connector is gone. Any other 404 is now a tool-list outage: the cached list is served as stale, or the RPC answers TOOLS_UNAVAILABLE. * fix(connectors): a connect from the page returns to the app after sign-in The sign-in link carries a return target only when the session's surface is the desktop. A chat session binds that surface. An account-owned call has no chat session, so nothing bound it: the link was minted without a return target and the browser ended on the portal's done page instead of coming back to Hermes. Every account-owned call now runs with the process's own surface bound, next to its profile scope. The operation thread copies that context, so the first link and every reissued link carry the return target and the operation id. * test(connectors): defer the new connector RPC coverage The tests for the new account RPCs, the portal client, the tool-list cache and the rule composer leave this PR and come back in one later change, after the API is settled. The same was done for #111008. Kept: the edits that existing tests need because the five connection RPCs now take `owner` instead of `session_id`, and the rename of the managed client seam. Removed: six new test files, their two fakes and the gateway conftest, and the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py, gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores all of them. * fix(cli): the connection panel hands the tool thread back at once The classic CLI's connection callback waited on a queue for the user's first decision. The operation's watcher starts only after the callback returns, and the watcher is what polls a hosted account, runs the 300-second deadline and sees Ctrl+C. For a hosted connector the panel opens on the sign-in link, where the only key that filled the queue was Cancel. The account was never polled: the user signed in, the panel never changed, and Esc reported the app as skipped. Ctrl+C set the interrupt flag but left the thread parked on the queue, so the turn never ended. The callback now opens the panel and returns, as the gateway's callback does for the desktop and the Ink TUI. The panel's actions already reach the operation through apply_answer on the UI thread, so the queue is removed. An install with a form still waits for Connect, because the backend starts no work for a pending row. Ctrl+C now settles the operation as `interrupt`, and open rows become `not_connected`. Checked on the e2e rig with the fake tool gateway: hosted connect completes on the third status read; Ctrl+C ends the turn and the polling stops; an MCP install with a plain and a secret field still saves config and both values. * fix(connectors): "run it again" lives in the library, so the classic CLI can use it Making a new sign-in link for a failed or expired hosted connector was implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not go through JSON-RPC: its Connect button on a failed row called apply_answer, which does nothing for a hosted operation because it has no MCP runner. The panel showed "Waiting…" until the deadline. `tools.connectors.run.reissue(operation, names)` now holds the checks and the per-kind action, and returns a refusal reason or None. The gateway maps each reason to the same JSON-RPC error as before. The CLI calls it for a hosted row; a refusal is shown on the row. MCP rows keep their path, because Connect on a failed MCP row re-sends the form values. Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint with `reinitiate: true`, a new link with a new connection id, then connected. * feat(connectors): the account list and disconnect go through the portal `connectors.accounts` and `connectors.accounts.remove` called the tool gateway. They now call the portal's account-management routes (`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`), which apply the organisation membership checks and write the disconnect audit row. There is no fallback to the gateway when the portal is unavailable, and a removal is never retried. The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`): the portal has no such route, and the operation watcher polls it once per second. `ConnectorClient.list_accounts` and `delete_account` are removed. The removed account's reply model carries `connector`, which both services send. * fix(connectors): the account RPCs answer what the portal really sends Checked against the portal source and against the staging and production services. - Errors are read from the upstream error code, not the HTTP status. A rule write answered 409 for a stale revision and for a user with no organisation; both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and 403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with the account owner map these too. - `connectors.policy.get` and `connectors.policy.set` carry `effective`: the portal's own result for this user, with its stamp and without provider or subject ids. Nothing is recomputed locally. - A rule write needs the revision the user saw: `expected_revision` is required and must be a revision string; a bad one is refused before any HTTP call. - A tool row carries `no_auth`; a list without the upstream flag is an invalid answer, not `false`. - `connectors.accounts.remove` returns the app of the removed account. An invalid id is `INVALID_PARAMS`. - The tool-list cache is per signed-in member (a hash of the token's `sub`), so two Nous accounts on one profile do not share entries. - A malformed slug is a local error, not a 404 from a server nobody called. Live, staging: no revision and a malformed revision refused locally; a good revision wrote one disabled Gmail tool and returned it in `effective`; the same revision again answered `POLICY_CONFLICT`; the list row showed the tool; the restore brought the member rules back to the start. Live, staging and production, read-only: all 60 tool lists (5483 tools) parse. * fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link Found by two adversarial reviews of the RPC layer and its types. - `connectors.connect` from a chat session with no open operation is refused (`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool registry with no card: it made a link nobody watched, returned a reply without the required `settled` field, and named an operation that was never registered. There is one way into an operation: the agent's call, or the account owner's `connectors.connect`. "Run it again" inside an open operation is unchanged. - `connection.update` for a session is routed by session key AND profile; two profiles with the same key no longer cross-deliver a sign-in link. The event payload gets the same redaction as the RPC replies. - `connection.respond` runs on the long-handler pool: an approval can start MCP OAuth discovery, which blocked every RPC of the gateway while it ran. - `connectors.list` rows are a closed snake_case model: `connector`, `enabled`, `connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`. The last one is display data: the gateway enforces the rules, the backend only passes the list on. The phantom `name` and `description` are gone, and the desktop uses the generated types instead of hand-written copies. - `tools_listing` (model-only data) no longer rides on `connectors.operation.status`. - `unavailable` is removed from the target states and settle reasons: nothing produces it. The contract generator now fails when a contract enum and its domain enum differ. - `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC. - The desktop sends `connection.respond` on the socket that holds the session, as wake and reissue already did. - Contract violations are logged every time, at error level. - An account connect whose prepare step is slow returns the live operation instead of an error while the operation keeps running. - The MCP-manifest `connector` field leaves this PR (it moves to a later one on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and `optional-mcps/` are untouched by this PR again. anti-slop: no net-new findings (15 touched files). * fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect The flag that tells the model "a connection card exists" was the session platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card, so there a connector call on an unconnected app handed the model the raw `connect_url` and told it to pass the link to the user. - The agent turn now declares how a link can reach the user (`tools/connectors/turn.py`): CARD when the agent was built with a connection callback, SIDE for a subagent or a background turn, LINK for a headless run (`-q`, cron, ACP, api_server, messaging). It is set once per tool batch in the agent loop and read by the connector dispatch path, which never sees the agent. The session platform decides return-to-app only. - CARD: the result carries `connect_card_available` and our hint, never the link and never the gateway's own hint. - SIDE: subagents (`delegate_tool`), gateway background turns and the classic CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections` tool on any path that derives the tool list, and a connector call on an unconnected app gets no link, only "report this to the main agent". - LINK is unchanged. - The hosted path with no card builds a detached operation, as the MCP path does, so no `connection.update` is emitted for an operation no client asked for. Names and docstrings that said "off desktop" now say "no card". - A settled card is dead on the desktop: `reissueConnectionTarget` and `respondToConnectionRequest` share one guard and send nothing for a settled or unknown operation. - The model-facing settled result no longer carries `connection_id`; the model repeated it to the user. Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI and classic CLI get `connect_card_available` and no link, the model opens the card, the account connects, the retried call succeeds; `-q` still gets the link; a subagent and a background turn have no `manage_connections` and get the no-link hint; on the desktop a card settled with Continue has no enabled control and sends no RPC. * feat(tools): every call made through tool_search + tool_call shows a real label on all three clients A bridged call showed as a generic `tool_call` row in the Ink TUI and as `⚡ tool_call` in the classic CLI, because the display looked the name up in the tool registry and bridged names are made at run time. The desktop labelled only batches that were all hosted connector calls, by parsing names itself. - `tools/tool_labels.py` is the one place that turns a bridged call into a label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL` → "Gmail · send email". MCP: "Linear · list issues". A local deferred tool keeps its own emoji, verb and primary-argument preview. A batch gets exactly one label per entry, always; an entry with no name gets a generic label. - Classic CLI: one row per inner call; the duration on the last row; the failure text on the row of the call that failed. With friendly labels off it prints what it printed before. - Gateway: tool start, progress and complete events and stored transcript rows carry a typed `labels` field. It does not depend on the classic CLI's display setting. Clients no longer parse tool names. - Ink TUI: rows from the labels; the verbose trail keeps Args and Result. - Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the labels, one row per call. The labels reach the row under a key no tool argument can use. The connect card it drew under a failed tool result is gone: after `CONNECTION_REQUIRED` the one way in is the agent's own `manage_connections` call. - `tool_search` and `tool_describe` rows read "Searching tools · " and "Reading tool details · N tools". Shown on the real desktop (video and screenshots), the Ink TUI and the classic CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a `CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the desktop rows with the classic CLI setting off. * fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly - A failed hosted search or describe used to return nothing, by design, so the model saw only local tools and told the user that a connected app was missing. The local results are unchanged; when the hosted leg failed, the `tool_search` and `tool_describe` results carry `connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}` and one hint line. A rejected token is `sign_in_expired`; an entitlement refusal or a shut gate adds nothing. `tool_describe` no longer lists those names under `not_found` next to "search again". - NS-932. The description now says which side a name belongs to: a bare name is a hosted connector account; `mcp: true` only when the user asks for an MCP server, a local server or an install, or when the name exists only in the catalog; connect and reconnect are hosted verbs, install, enable and authorize are MCP verbs. It names the three clients that draw a card. - A misrouted target is refused with the call that works. Only when the gateway does not know the connector (confirmed on that failure path) and the name is a catalog entry does the target fail with "X is a local MCP server. Call manage_connections with action install ...". It is a per-target outcome: other targets of the same call keep their links and their card. A vendor failure on a name both sides know stays an ordinary failed row. The MCP side mirrors it, and never for an entry that is only not installed. - "Do not re-ask after a skip or a timeout" no longer stops the model when the USER asks for that app again; the description and the settled-result notes say so. A builder saw the model refuse a direct user request. Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes MCP; "connect fx-noauth" reaches the MCP install card in one corrective round with no hosted mint; a two-target call where one is misrouted still connects the other with exactly one mint. * fix(tui): the connection card answers every key, shows what is happening, and is dead once settled Reproduced on the real Ink TUI with the rig, then fixed: - The keyboard was dead during the sign-in wait: the card kept a `submitting` flag that the normal OAuth path never cleared, and Esc went through the same guard. The in-flight state now belongs to the answered row and clears when that row moves, when any later frame of the operation arrives, or after five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn (the input handler had no branch for this overlay); Shift+arrows scroll the transcript and the card ignores them; arrow keys no longer move the text cursor and the field focus at once. - The card was lost at turn idle: the overlay flag was cleared while the operation stayed in the store, and a resume dropped the pending card. The flag survives idle, a resume shows the pending card again, a session switch clears it. - States with no branch: `not_connected` and a row with no link fell into the credential form; `expired` vanished with no note. The title and the row text now name the action (connect, reconnect, install, enable, authorize); a failed or expired row with no fields offers Try again / Skip; a failed row WITH fields reopens the form over the typed draft, with the failure above it. - A settled card is dead: at settle the overlay closes and one transcript line per app states the outcome. A settled or dismissed operation id is remembered, so no replay or resume can reopen its card. Esc in the last "Finishing…" moment hides the card and still writes the outcome lines. - A failed `connection.respond` and a browser that did not open are shown on the card in one sentence. Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of a credential field to the string "[REDACTED]". On the desktop every credential field therefore rendered as a password and lost its prefilled default. A boolean is no longer redacted. * chore(connectors): remove the comments and docstrings this branch added Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...), `// SAFETY:` lines, and the docstrings of the contract models under `tui_gateway/contracts/`, which become the descriptions in the generated OpenRPC and TypeScript. Checked that no code changed: every Python file has the same AST as before once docstrings and `pass` are ignored (62 files), and every TypeScript file prints the same with comments stripped by the TypeScript printer (32 files). The generated contract files are unchanged. * fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures Found by the end-to-end runs on the pushed head. - Desktop: after a window reload, Continue on the restored card sent nothing. The answer looked up the backend that holds the session with the runtime session id, the lookup wants the stored id, and a failed lookup returned silently. When the lookup gives no owner the answer now goes out on the window's active socket, which is what main does. - `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in, a refused scope, a non-member and a missing organisation alike: the handler runs with the gateway's globals and did not import the reason enum, so its own error mapping raised. `connectors.accounts.remove` caught auth failures in its generic branch. `org_required` was mapped on `policy.set` only. All six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`, `ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers. * wip(desktop): port the Connectors tab files and wiring onto the #115191 head * wip(desktop): Connectors tab on the #115191 contract, catalog arm removed, audit defects fixed * wip(desktop): Connectors tab passes the anti-slop ratchet; dormant two-ways code and the Available collapse removed * wip(mcp): every server row says whether config or a plugin provides it; writes refuse plugin rows * wip(desktop): Connectors tab, the owner's first live round (custom MCP form, kind words, compact dialog) * wip(desktop): the connector dialog fits its content * wip(desktop): catalog MCPs show on the Connectors tab until the catalog dies; connector_slug pairs a manifest with its managed app; the closed-gate state * wip(desktop): connectors cache v3, the seed shape gained connector_slug * wip(desktop): the owner's answers on the connectors page A plugin-provided server now shows its tool list: the dialog probes it through the existing read-only test endpoint, shows the tools without switches (the plugin owns them), and shows the probe's error with a Retry when the server cannot start. Its card is named after the server key in the plugin's mcp.json, not the namespaced runtime key. The paste box no longer parses `--header` on a `hermes mcp add` line; the CLI has no such flag. The rule write sends the member layer's revision only. The portal always returns a member layer (baseline revision when no row exists) and compares the write against that row, so the effective revision was never the right guess. Verified live on staging: two writes in a row, both accepted, policy restored. The page cache keeps every read for signed-in accounts too and only clears itself when the account is signed out. The storage version moves to v4 so old blobs are ignored. * chore(desktop): strip the prose comments the connectors page branch added Comments and docstrings this branch added relative to main are gone; tool directives, SAFETY lines and the contract docstrings that feed the generated OpenRPC stay. Guards: Python AST and TypeScript printer output are identical before and after; ruff, tsc, eslint, the ratchet and the generated contracts are unchanged. --- .gitignore | 3 + .../capabilities/connectors/add-dialog.tsx | 124 ++++ .../connectors/add-server-draft.ts | 151 +++++ .../connectors/add-server-form.tsx | 326 +++++++++++ .../capabilities/connectors/catalog-mark.tsx | 21 + .../connectors/category-picker.tsx | 74 +++ .../connectors/connect-element.tsx | 49 ++ .../connectors/connector-dialog.tsx | 372 ++++++++++++ .../capabilities/connectors/connector-kind.ts | 19 + .../connectors/connector-row-card.tsx | 198 +++++++ .../connectors/connectors-directory.tsx | 225 ++++++++ .../connectors/connectors-tab.tsx | 388 +++++++++++++ .../connectors/data/account-operations.ts | 167 ++++++ .../capabilities/connectors/data/deep-link.ts | 29 + .../app/capabilities/connectors/data/join.ts | 309 ++++++++++ .../app/capabilities/connectors/data/keys.ts | 60 ++ .../capabilities/connectors/data/mutations.ts | 243 ++++++++ .../capabilities/connectors/data/persist.ts | 283 +++++++++ .../capabilities/connectors/data/portal.ts | 29 + .../capabilities/connectors/data/prefetch.ts | 44 ++ .../capabilities/connectors/data/queries.ts | 227 ++++++++ .../app/capabilities/connectors/data/rpc.ts | 152 +++++ .../capabilities/connectors/derive-page.ts | 94 +++ .../capabilities/connectors/derive-tools.ts | 312 ++++++++++ .../src/app/capabilities/connectors/derive.ts | 415 +++++++++++++ .../capabilities/connectors/dialog-menu.tsx | 60 ++ .../connectors/hint-vocabulary.ts | 109 ++++ .../capabilities/connectors/hosted-dialog.tsx | 155 +++++ .../capabilities/connectors/local-dialog.tsx | 120 ++++ .../connectors/local-server-control.tsx | 98 ++++ .../capabilities/connectors/local-slots.tsx | 71 +++ .../connectors/plugin-tools-panel.tsx | 108 ++++ .../app/capabilities/connectors/tool-row.tsx | 146 +++++ .../connectors/tools-filter-bar.tsx | 140 +++++ .../capabilities/connectors/tools-list.tsx | 543 ++++++++++++++++++ .../capabilities/connectors/tools-panel.tsx | 157 +++++ .../capabilities/connectors/tools-status.tsx | 120 ++++ .../capabilities/connectors/tools-summary.tsx | 127 ++++ .../src/app/capabilities/connectors/types.ts | 219 +++++++ .../connectors/use-tools-editor.ts | 176 ++++++ .../capabilities/connectors/ways-section.tsx | 148 +++++ apps/desktop/src/app/capabilities/index.tsx | 31 +- .../capabilities/mcp/install-catalog-entry.ts | 36 ++ .../src/app/capabilities/mcp/mcp-avatar.tsx | 25 + .../src/app/capabilities/mcp/mcp-doc.ts | 199 +++++++ .../src/app/capabilities/mcp/mcp-editor.tsx | 98 ++++ .../src/app/capabilities/mcp/mcp-logs.tsx | 65 +++ .../src/app/capabilities/mcp/mcp-status.ts | 160 ++++++ .../src/app/capabilities/mcp/use-mcp-draft.ts | 195 +++++++ .../app/capabilities/mcp/use-mcp-probes.ts | 162 ++++++ .../app/capabilities/mcp/use-mcp-servers.ts | 429 ++++++++++++++ .../desktop/src/app/command-palette/index.tsx | 10 +- .../contrib/hooks/use-desktop-integrations.ts | 13 +- .../contrib/mcp-install-deeplink-dialog.tsx | 2 +- apps/desktop/src/app/routes.ts | 6 +- .../src/app/routes.workspace-reveal.test.ts | 10 +- .../gateway-event/input-requests.ts | 7 + .../src/app/settings/moved-tabs.test.ts | 2 +- apps/desktop/src/app/settings/moved-tabs.ts | 21 +- .../src/components/ui/connector-card.tsx | 2 +- apps/desktop/src/components/ui/switch.tsx | 2 +- apps/desktop/src/i18n/en.ts | 240 ++++++++ apps/desktop/src/i18n/types.ts | 219 +++++++ apps/desktop/src/lib/connector-tools.ts | 15 +- apps/desktop/src/lib/mcp-import.ts | 119 +++- apps/desktop/src/lib/mcp-servers.ts | 2 + apps/desktop/src/lib/mcp-tool-filter.test.ts | 26 +- apps/desktop/src/lib/mcp-tool-filter.ts | 30 + apps/desktop/src/store/connection-request.ts | 6 +- apps/desktop/src/store/mcp-health.ts | 6 +- apps/desktop/src/types/hermes.ts | 1 + apps/shared/src/gateway-contract.generated.ts | 6 + apps/shared/src/gateway-contract.openrpc.json | 56 +- hermes_cli/mcp_catalog.py | 12 +- hermes_cli/plugins.py | 4 + hermes_cli/plugins_ledger.py | 2 +- hermes_cli/plugins_loader.py | 1 + hermes_cli/web_routers/mcp.py | 65 ++- hermes_cli/web_server_mcp.py | 4 +- optional-mcps/airtable/manifest.yaml | 1 + optional-mcps/asana/manifest.yaml | 1 + optional-mcps/atlassian/manifest.yaml | 1 + optional-mcps/attio/manifest.yaml | 1 + optional-mcps/betterstack/manifest.yaml | 1 + optional-mcps/calendly/manifest.yaml | 1 + optional-mcps/canva/manifest.yaml | 1 + optional-mcps/cloudflare/manifest.yaml | 1 + optional-mcps/datadog/manifest.yaml | 1 + optional-mcps/dropbox/manifest.yaml | 1 + optional-mcps/figma/manifest.yaml | 1 + optional-mcps/intercom/manifest.yaml | 1 + optional-mcps/linear/manifest.yaml | 1 + optional-mcps/neon/manifest.yaml | 1 + optional-mcps/notion/manifest.yaml | 1 + optional-mcps/prisma-postgres/manifest.yaml | 1 + optional-mcps/railway/manifest.yaml | 1 + optional-mcps/sentry/manifest.yaml | 1 + optional-mcps/stripe/manifest.yaml | 1 + optional-mcps/supabase/manifest.yaml | 1 + optional-mcps/todoist/manifest.yaml | 1 + optional-mcps/vercel/manifest.yaml | 1 + tests/hermes_cli/test_mcp_catalog.py | 21 + tests/tui_gateway/test_mcp_profile_rpcs.py | 2 + tui_gateway/contracts/tools_mcp_plugins.py | 10 + tui_gateway/mcp_rpc_helpers.py | 31 +- tui_gateway/methods_tools.py | 52 +- 106 files changed, 9149 insertions(+), 89 deletions(-) create mode 100644 apps/desktop/src/app/capabilities/connectors/add-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/add-server-draft.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/add-server-form.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/catalog-mark.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/category-picker.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connect-element.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-kind.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/connector-row-card.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connectors-directory.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/data/account-operations.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/deep-link.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/join.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/keys.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/mutations.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/persist.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/portal.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/prefetch.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/queries.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/data/rpc.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive-page.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive-tools.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/derive.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/dialog-menu.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/hint-vocabulary.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-dialog.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-server-control.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/local-slots.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/plugin-tools-panel.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tool-row.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-filter-bar.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-list.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-panel.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-status.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/tools-summary.tsx create mode 100644 apps/desktop/src/app/capabilities/connectors/types.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/use-tools-editor.ts create mode 100644 apps/desktop/src/app/capabilities/connectors/ways-section.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/install-catalog-entry.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-avatar.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-doc.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-editor.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-logs.tsx create mode 100644 apps/desktop/src/app/capabilities/mcp/mcp-status.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-draft.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-probes.ts create mode 100644 apps/desktop/src/app/capabilities/mcp/use-mcp-servers.ts diff --git a/.gitignore b/.gitignore index 63c2583b76..227ac9ac97 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ data/ # Bundled community plugin index seed (shipped as package data) — the bare # `data/` pattern above would otherwise swallow it. !hermes_cli/data/ +# The Connectors page's wire layer (apps/desktop/src/app/capabilities/connectors/data/) +# — same reason: the bare `data/` pattern above would otherwise swallow it. +!apps/desktop/src/app/capabilities/connectors/data/ .pytest_cache/ test_durations.json .pytest-cache/ diff --git a/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx new file mode 100644 index 0000000000..f9ad6342b4 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-dialog.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { notifyError } from '@/store/notifications' + +import { McpJsonEditor } from '../mcp/mcp-editor' +import type { McpServersController } from '../mcp/use-mcp-servers' + +import { type AddServerDraft, EMPTY_ADD_DRAFT, entryOfDraft, isDraftComplete } from './add-server-draft' +import { AddServerForm } from './add-server-form' +import { setMcpBearerToken } from './data/rpc' + +export interface AddServerDialogProps { + controller: McpServersController + onOpenChange: (open: boolean) => void + open: boolean + profile: ProfileScope +} + +export function AddServerDialog({ controller, onOpenChange, open, profile }: AddServerDialogProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const [draft, setDraft] = useState(EMPTY_ADD_DRAFT) + const [raw, setRaw] = useState(false) + const [saving, setSaving] = useState(false) + + const name = draft.name.trim() + const nameTaken = name !== '' && name in controller.servers + + const discardRawDraft = () => { + if (controller.dirty) { + controller.resetDraft(controller.servers) + } + } + + const close = () => { + discardRawDraft() + setDraft(EMPTY_ADD_DRAFT) + setRaw(false) + onOpenChange(false) + } + + const openRaw = () => { + controller.addServer() + setRaw(true) + } + + const leaveRaw = () => { + discardRawDraft() + setRaw(false) + } + + const save = async () => { + setSaving(true) + + try { + if (!(await controller.addServerEntry(name, entryOfDraft(draft)))) { + return + } + + if (draft.transport === 'http' && draft.auth === 'bearer' && draft.bearer.trim() !== '') { + await setMcpBearerToken(profile, name, draft.bearer.trim()) + controller.refetchConfig() + } + + close() + } catch (err) { + notifyError(err, copy.saveFailed) + } finally { + setSaving(false) + } + } + + return ( + (next ? onOpenChange(true) : close())} open={open}> + +
+ {copy.title} +
+ {copy.hint} + + {raw ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+ {raw ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts new file mode 100644 index 0000000000..e2d069036e --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-draft.ts @@ -0,0 +1,151 @@ +import { type McpServerEntry, normalizeEntry } from '@/lib/mcp-servers' + +export type AddServerAuth = 'bearer' | 'none' | 'oauth' + +export type AddServerTransport = 'http' | 'stdio' + +export interface DraftValue { + id: number + value: string +} + +export interface DraftPair { + id: number + key: string + value: string +} + +export interface AddServerDraft { + args: DraftValue[] + auth: AddServerAuth + bearer: string + command: string + cwd: string + env: DraftPair[] + headers: DraftPair[] + name: string + passthrough: DraftValue[] + transport: AddServerTransport + url: string +} + +let rowCounter = 0 + +export const nextRowId = (): number => ++rowCounter + +export const emptyValue = (value = ''): DraftValue => ({ id: nextRowId(), value }) + +export const emptyPair = (key = '', value = ''): DraftPair => ({ id: nextRowId(), key, value }) + +export const EMPTY_ADD_DRAFT: AddServerDraft = { + args: [], + auth: 'none', + bearer: '', + command: '', + cwd: '', + env: [], + headers: [], + name: '', + passthrough: [], + transport: 'stdio', + url: '' +} + +export const isDraftComplete = (draft: AddServerDraft): boolean => + draft.name.trim() !== '' && (draft.transport === 'stdio' ? draft.command.trim() !== '' : draft.url.trim() !== '') + +const filledPairs = (rows: readonly DraftPair[]): Record => + Object.fromEntries(rows.filter(row => row.key.trim() !== '').map(row => [row.key.trim(), row.value])) + +const filledValues = (rows: readonly DraftValue[]): string[] => + rows.map(row => row.value.trim()).filter(value => value !== '') + +const envReference = (name: string): string => `\${${name}}` + +function httpEntry(draft: AddServerDraft): McpServerEntry { + const headers = filledPairs(draft.headers) + const entry: McpServerEntry = { url: draft.url.trim() } + + if (Object.keys(headers).length > 0) { + entry.headers = headers + } + + if (draft.auth === 'oauth') { + entry.auth = 'oauth' + } + + return entry +} + +function stdioEntry(draft: AddServerDraft): McpServerEntry { + const env = { ...filledPairs(draft.env) } + + for (const name of filledValues(draft.passthrough)) { + env[name] = envReference(name) + } + + const args = filledValues(draft.args) + const entry: McpServerEntry = { command: draft.command.trim() } + + if (args.length > 0) { + entry.args = args + } + + if (Object.keys(env).length > 0) { + entry.env = env + } + + if (draft.cwd.trim() !== '') { + entry.cwd = draft.cwd.trim() + } + + return entry +} + +export const entryOfDraft = (draft: AddServerDraft): McpServerEntry => + draft.transport === 'http' ? httpEntry(draft) : stdioEntry(draft) + +type EntryValue = McpServerEntry[string] + +const isString = (value: EntryValue): value is string => Object.prototype.toString.call(value) === '[object String]' + +const asString = (value: EntryValue): string => (isString(value) ? value : '') + +const asRecord = (value: EntryValue): McpServerEntry => + value instanceof Object && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} + +const asValues = (value: EntryValue): DraftValue[] => + Array.isArray(value) ? value.map((entry: EntryValue) => emptyValue(asString(entry))) : [] + +const forwarded = (key: string, value: EntryValue): boolean => value === envReference(key) + +export function draftFromEntry(name: string, raw: McpServerEntry, previous: AddServerDraft): AddServerDraft { + const entry = normalizeEntry(raw) + const url = asString(entry.url) + const named = name || previous.name + + if (url !== '') { + return { + ...EMPTY_ADD_DRAFT, + auth: asString(entry.auth) === 'oauth' ? 'oauth' : 'none', + bearer: previous.bearer, + headers: Object.entries(asRecord(entry.headers)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + transport: 'http', + url + } + } + + const env = Object.entries(asRecord(entry.env)) + + return { + ...EMPTY_ADD_DRAFT, + args: asValues(entry.args), + command: asString(entry.command), + cwd: asString(entry.cwd), + env: env.filter(([key, value]) => !forwarded(key, value)).map(([key, value]) => emptyPair(key, asString(value))), + name: named, + passthrough: env.filter(([key, value]) => forwarded(key, value)).map(([key]) => emptyValue(key)), + transport: 'stdio' + } +} diff --git a/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx new file mode 100644 index 0000000000..e10630e7d3 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/add-server-form.tsx @@ -0,0 +1,326 @@ +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { Textarea } from '@/components/ui/textarea' +import { useI18n } from '@/i18n' +import type { Translations } from '@/i18n/types' +import { parseMcpImport } from '@/lib/mcp-import' + +import { + type AddServerAuth, + type AddServerDraft, + type AddServerTransport, + draftFromEntry, + type DraftPair, + type DraftValue, + emptyPair, + emptyValue +} from './add-server-draft' + +type AddCopy = Translations['connectorsPage']['add'] + +type SetDraft = (patch: Partial) => void + +export interface AddServerFormProps { + draft: AddServerDraft + nameTaken: boolean + onChange: (next: AddServerDraft) => void +} + +export function AddServerForm({ draft, nameTaken, onChange }: AddServerFormProps) { + const { t } = useI18n() + const copy = t.connectorsPage.add + const set: SetDraft = patch => onChange({ ...draft, ...patch }) + + return ( +
+ + + + set({ name: event.currentTarget.value })} size="sm" value={draft.name} /> + {nameTaken ?

{copy.nameTaken}

: null} +
+ +
+ set({ transport: next })} + options={[ + { id: 'stdio', label: copy.typeStdio }, + { id: 'http', label: copy.typeHttp } + ]} + value={draft.transport} + /> +
+ + {draft.transport === 'stdio' ? ( + + ) : ( + + )} +
+ ) +} + +function StdioFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + return ( + <> + + set({ command: event.currentTarget.value })} size="sm" value={draft.command} /> + + + set({ args })} + removeLabel={copy.removeRow} + rows={draft.args} + /> + + set({ env })} + rows={draft.env} + /> + + set({ passthrough })} + placeholder={copy.keyPlaceholder} + removeLabel={copy.removeRow} + rows={draft.passthrough} + /> + + + set({ cwd: event.currentTarget.value })} size="sm" value={draft.cwd} /> + + + ) +} + +const AUTH_OPTIONS: readonly AddServerAuth[] = ['none', 'oauth', 'bearer'] + +function HttpFields({ copy, draft, set }: { copy: AddCopy; draft: AddServerDraft; set: SetDraft }) { + const authLabel = { bearer: copy.authBearer, none: copy.authNone, oauth: copy.authOauth } + + return ( + <> + + set({ url: event.currentTarget.value })} size="sm" value={draft.url} /> + + + set({ headers })} + rows={draft.headers} + /> + +
+ set({ auth: next })} + options={AUTH_OPTIONS.map(id => ({ id, label: authLabel[id] }))} + value={draft.auth} + /> +
+ + {draft.auth === 'bearer' ? ( + + set({ bearer: event.currentTarget.value })} + size="sm" + type="password" + value={draft.bearer} + /> + + ) : null} + + ) +} + +function PasteBox({ + copy, + onFill, + previous +}: { + copy: AddCopy + onFill: (next: AddServerDraft) => void + previous: AddServerDraft +}) { + const [text, setText] = useState('') + const [failed, setFailed] = useState(false) + + const read = (value: string) => { + setText(value) + + if (value.trim() === '') { + setFailed(false) + + return + } + + const entries = parseMcpImport(value) + + setFailed(entries === null) + + if (entries !== null) { + onFill(draftFromEntry(entries[0].name, entries[0].config, previous)) + } + } + + return ( +
+