From 1acb5dd90fb202f1a33acee893d903ce2bfa6529 Mon Sep 17 00:00:00 2001 From: yilsem Date: Wed, 7 Oct 2026 22:37:49 +0300 Subject: [PATCH] fix(mobile): wire plugin catalog and session-scoped subagent controls --- .gitea/workflows/ci.yml | 5 +- apps/desktop/package.json | 2 +- docs/aiturk-mobile-beta20.md | 20 + hermes_cli/_subprocess_compat.py | 5 +- hermes_cli/aiturk_plugin_catalog.py | 54 ++ hermes_cli/plugin_catalog.py | 533 ++++++++++++++++++ hermes_cli/plugins_cmd.py | 24 +- hermes_cli/web_models.py | 5 +- hermes_cli/web_server.py | 22 +- package-lock.json | 2 +- plugin-catalog/README.md | 194 +++++++ plugin-catalog/blender.yaml | 23 + .../claude-subscription-directsdk.yaml | 22 + plugin-catalog/hermes-memory-wiki.yaml | 15 + plugin-catalog/hermes-telegram-business.yaml | 15 + plugin-catalog/homeassistant.yaml | 23 + plugin-catalog/nvidia-app.yaml | 24 + plugin-catalog/nvidia-broadcast.yaml | 24 + plugin-catalog/removed.yaml | 12 + plugin-catalog/snyk.yaml | 17 + plugin-catalog/touchdesigner.yaml | 16 + .../hermes_cli/test_aiturk_plugin_catalog.py | 56 ++ tests/hermes_cli/test_plugin_catalog.py | 300 ++++++++++ tests/hermes_cli/test_plugin_install_ref.py | 3 +- tests/tui_gateway/test_mobile_subagents.py | 76 +++ tui_gateway/methods_session.py | 43 +- tui_gateway/mobile_subagents.py | 55 ++ 27 files changed, 1573 insertions(+), 17 deletions(-) create mode 100644 docs/aiturk-mobile-beta20.md create mode 100644 hermes_cli/aiturk_plugin_catalog.py create mode 100644 hermes_cli/plugin_catalog.py create mode 100644 plugin-catalog/README.md create mode 100644 plugin-catalog/blender.yaml create mode 100644 plugin-catalog/claude-subscription-directsdk.yaml create mode 100644 plugin-catalog/hermes-memory-wiki.yaml create mode 100644 plugin-catalog/hermes-telegram-business.yaml create mode 100644 plugin-catalog/homeassistant.yaml create mode 100644 plugin-catalog/nvidia-app.yaml create mode 100644 plugin-catalog/nvidia-broadcast.yaml create mode 100644 plugin-catalog/removed.yaml create mode 100644 plugin-catalog/snyk.yaml create mode 100644 plugin-catalog/touchdesigner.yaml create mode 100644 tests/hermes_cli/test_aiturk_plugin_catalog.py create mode 100644 tests/hermes_cli/test_plugin_catalog.py create mode 100644 tests/tui_gateway/test_mobile_subagents.py create mode 100644 tui_gateway/mobile_subagents.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f5e85a1..a871c91 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -101,7 +101,10 @@ jobs: $sayilmayan | ForEach-Object { " sayilmadi: $_" } & $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f ` tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py ` - tests/cli/test_single_query_session_finalize.py @deselect -q -p no:cacheprovider -rf + tests/cli/test_single_query_session_finalize.py ` + tests/hermes_cli/test_plugin_catalog.py tests/hermes_cli/test_aiturk_plugin_catalog.py ` + tests/hermes_cli/test_plugin_install_ref.py ` + tests/tui_gateway/test_mobile_subagents.py @deselect -q -p no:cacheprovider -rf $kod = $LASTEXITCODE "pytest cikis kodu: $kod" exit $kod diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6da799a..76d8777 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.19", + "version": "1.0.0-beta.20", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/docs/aiturk-mobile-beta20.md b/docs/aiturk-mobile-beta20.md new file mode 100644 index 0000000..e3ef19b --- /dev/null +++ b/docs/aiturk-mobile-beta20.md @@ -0,0 +1,20 @@ +# AITURK IDE beta.20 mobile adapter + +The mobile catalog now uses the official live Hermes catalog, its cache and +removed-entry list. Parser and nine official offline seed entries are ported +from NousResearch/hermes-agent ad12263a5ed43109d3942a8af04d4cef210fa6ae. +Only PyYAML import compatibility differs in the parser. The existing installer +checks out the catalog's exact commit; client URLs cannot override catalog names. +Version/platform incompatibility is reported before installation. + +Mobile subagent roster/tail RPCs are connected to the existing live registry. +Roster supports durable conversation lineage after reconnect, like upstream. +Tail and interrupt retain exact live-session/transport authority. Transcript +reads are bounded and confined to the profile's delegation log directory. +The existing mobile fork fallback already uses session.branch only for -32601; +it was not a missing feature and is unchanged. + +No new model, team, cron job, permission, or account is installed by these changes. +The mobile Keys/Files/Vault/Connectors exclusions remain as before; this release +does not claim those unsupported remote screens are implemented. Android beta.5 +can use the new endpoints without a new APK. Physical-phone acceptance is separate. diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 12350d2..e56dd2e 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -769,7 +769,10 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str: openai/codex#36793). ``process_group`` only changes which group the child belongs to; it does not detach the terminal or alter the fast path. """ - result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env()) + env = noninteractive_git_env() + # Read-only probes must not fetch missing trees in partial clones. + env["GIT_NO_LAZY_FETCH"] = "1" + result = bounded_probe_run(argv, timeout=timeout, env=env) if result is None or result.returncode != 0: return "" return (result.stdout or "").strip() diff --git a/hermes_cli/aiturk_plugin_catalog.py b/hermes_cli/aiturk_plugin_catalog.py new file mode 100644 index 0000000..7a822e2 --- /dev/null +++ b/hermes_cli/aiturk_plugin_catalog.py @@ -0,0 +1,54 @@ +"""AITURK adapter for the upstream catalog and existing pinned installer. + +Catalog parsing/cache comes from NousResearch/hermes-agent ad12263a5ed4. +The catalog supplies the repository and immutable revision, never the client. +""" +from dataclasses import asdict +import sys + +from hermes_cli import plugin_catalog as catalog + + +def catalog_payload(): + from hermes_cli.plugins_cmd import _discover_all_plugins, _read_install_metadata + + installed = {row[0] for row in _discover_all_plugins()} + metadata = _read_install_metadata() + removed = catalog.resolved_removed_entries() + entries = [] + for entry in catalog.load_catalog_live(): + if catalog.match_removed(entry.name, removed) or catalog.match_removed(entry.repo, removed): + continue + row = entry.to_dict() + record = metadata.get(entry.name, {}) + revision = record.get("revision") + row.update(installed=entry.name in installed, installed_sha=revision, + update_available=bool(revision and revision != entry.sha), + sha_short=entry.sha[:12], capability_summary=catalog.entry_capability_summary(entry)) + entries.append(row) + return {"entries": entries, "removed": [asdict(row) for row in removed]} + + +def install_catalog_plugin(name, *, force=False, enable=True): + from hermes_cli.plugins_cmd import dashboard_install_plugin + + entry = catalog.get_live_catalog_entry(name) + if entry is None: + return {"ok": False, "error": "Eklenti katalogda bulunamadı."} + removed = catalog.resolved_removed_entries() + if catalog.match_removed(name, removed) or catalog.match_removed(entry.repo, removed): + return {"ok": False, "error": "Bu eklenti katalogdan kaldırılmış."} + platform = {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform) + supported = {"win": "windows", "win32": "windows", "darwin": "macos", "mac": "macos"} + if entry.platforms and platform not in {supported.get(p.lower(), p.lower()) for p in entry.platforms}: + return {"ok": False, "error": "Bu eklenti bilgisayarın işletim sistemini desteklemiyor."} + if entry.requires_hermes: + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from hermes_cli import __version__ + try: + compatible = __version__ in SpecifierSet(entry.requires_hermes) + except InvalidSpecifier: + return {"ok": False, "error": "Katalogdaki sürüm koşulu geçersiz; eklenti kurulmadı."} + if not compatible: + return {"ok": False, "error": f"Bu eklenti Hermes {entry.requires_hermes} gerektiriyor."} + return dashboard_install_plugin(entry.install_identifier, force=force, enable=enable, ref=entry.sha) diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py new file mode 100644 index 0000000..dae0fa8 --- /dev/null +++ b/hermes_cli/plugin_catalog.py @@ -0,0 +1,533 @@ +"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo. + +Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree +``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS +the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list +(installs of a removed name/repo are refused with the recorded reason). Full policy: +``plugin-catalog/README.md``. + +Live refresh: the docs build publishes the same data as ONE JSON document +(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so +an installed Hermes sees new entries and removals without updating. A fetch failure reuses the last valid +cached copy regardless of age, then falls back to the in-tree copy when no valid cache exists. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +logger = logging.getLogger(__name__) + +CATALOG_TIERS = ("official", "community") +# Browse taxonomy for the catalog page / picker. Entries without one land on the Desktop shelf +# (the common case for community submissions); "general" is for plugins that fit no shelf. +CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general") +LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json" +LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60 +# Offline pins expire, but cached removals remain a permanent kill list. +LIVE_CATALOG_MAX_STALE_SECONDS = 24 * 60 * 60 +LIVE_CATALOG_FAILURE_TTL_SECONDS = 60.0 +_REQUEST_TIMEOUT = 5.0 +_MAX_LIVE_BYTES = 2 * 1024 * 1024 + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$") +# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to +# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha. +IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com") +IMAGE_HOST_SUFFIX = ".githubusercontent.com" + + +def is_allowed_image_url(url: str) -> bool: + from urllib.parse import urlsplit + parts = urlsplit(url) + host = (parts.hostname or "").lower() + return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX)) +_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$") + + +@dataclass +class RemovedEntry: + name: str + repo: str = "" + reason: str = "" + date: str = "" + + +@dataclass +class CatalogCapabilities: + provides_tools: List[str] = field(default_factory=list) + provides_hooks: List[str] = field(default_factory=list) + provides_middleware: List[str] = field(default_factory=list) + requires_env: List[str] = field(default_factory=list) + + +@dataclass +class PluginCatalogEntry: + name: str # catalog key, [a-z0-9_-]{1,64} + repo: str # https:// git URL + sha: str # 40-hex pinned commit — mandatory + description: str + maintainer: str + tier: str = "community" + category: str = "desktop" + requires_hermes: str = "" + subdir: str = "" + docs_url: str = "" + version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed + image: str = "" # https image URL on a GitHub host; shown on catalog cards + screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/ + readme: bool = False # docs site renders the README from the pinned commit on the entry's page + platforms: List[str] = field(default_factory=list) # empty = all OSes + title: str = "" # human name ("NVIDIA App"); empty = derived from ``name`` + onboarding: bool = False # curated: offered on the desktop onboarding card + capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities) + known_issues: List[str] = field(default_factory=list) # #124058: informational; drivers come from plugin-catalog/*.yaml + + @property + def install_identifier(self) -> str: + """``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries).""" + return f"{self.repo}#{self.subdir}" if self.subdir else self.repo + + def to_dict(self) -> Dict[str, Any]: + caps = self.capabilities + return { + "name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description, + "maintainer": self.maintainer, "tier": self.tier, "category": self.category, + "requires_hermes": self.requires_hermes, + "subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image, + "screenshots": list(self.screenshots), "readme": self.readme, + "platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding, + "capabilities": { + "provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks), + "provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env), + }, + "known_issues": list(self.known_issues), + } + + +def get_catalog_dir() -> Path: + """The ``plugin-catalog/`` directory shipped with this checkout.""" + return Path(__file__).resolve().parent.parent / "plugin-catalog" + + +# ── Parsing ────────────────────────────────────────────────────────────────── + +def _str_list(raw: Any) -> List[str]: + return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else [] + + +def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]: + """Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure.""" + if not isinstance(data, dict): + logger.warning("Plugin catalog: %s: entry must be a mapping", label) + return None + name = str(data.get("name") or "") + repo = str(data.get("repo") or "") + sha = str(data.get("sha") or "").strip().lower() + tier = str(data.get("tier") or "community") + category = str(data.get("category") or "desktop") + problem = ( + f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name) + else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://") + else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha) + else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS + else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})" + if category not in CATALOG_CATEGORIES + else None) + if problem: + logger.warning("Plugin catalog: %s: %s", label, problem) + return None + caps_raw = data.get("capabilities") + caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {} + version = str(data.get("version") or "").strip() + if version and not _VERSION_RE.match(version): + logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version) + version = "" + image = str(data.get("image") or "").strip() + if image and not is_allowed_image_url(image): + logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image) + image = "" + screenshots = [s for s in _str_list(data.get("screenshots")) if is_allowed_image_url(s)] + if len(screenshots) != len(_str_list(data.get("screenshots"))): + logger.warning("Plugin catalog: %s: ignoring screenshots off GitHub hosts", label) + return PluginCatalogEntry( + name=name, repo=repo, sha=sha, + description=str(data.get("description") or "").strip(), + maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category, + requires_hermes=str(data.get("requires_hermes") or "").strip(), + subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(), + version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False, + platforms=_str_list(data.get("platforms")), + title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True, + known_issues=_str_list(data.get("known_issues")), + capabilities=CatalogCapabilities( + provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")), + provides_middleware=_str_list(caps.get("provides_middleware")), + requires_env=_str_list(caps.get("requires_env"))), + ) + + +def _read_yaml(path: Path) -> Any: + try: + return yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} + except Exception as exc: + logger.warning("Plugin catalog: failed to read %s: %s", path, exc) + return None + + +def _removed_from_list(raw_list: Any) -> List[RemovedEntry]: + if not isinstance(raw_list, list): + return [] + return [ + RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""), + date=str(r.get("date") or "")) + for r in raw_list if isinstance(r, dict) and r.get("name")] + + +# ── In-tree catalog ────────────────────────────────────────────────────────── + +def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]: + """Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name. + Malformed entries are skipped with a warning — never raises.""" + root = catalog_dir or get_catalog_dir() + if not root.is_dir(): + return [] + entries = [] + for path in sorted(root.glob("*.yaml")): + if path.name == "removed.yaml": + continue + data = _read_yaml(path) + entry = entry_from_mapping(data, str(path)) if data is not None else None + if entry is not None: + entries.append(entry) + return entries + + +def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]: + """``removed.yaml``'s ``removed:`` list; missing/malformed → empty.""" + path = (catalog_dir or get_catalog_dir()) / "removed.yaml" + data = _read_yaml(path) if path.is_file() else None + return _removed_from_list(data.get("removed")) if isinstance(data, dict) else [] + + +def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]: + return next((e for e in load_catalog(catalog_dir) if e.name == name), None) + + +def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]: + """Case-insensitive substring match over name, description and declared tools; empty query = all.""" + q = (query or "").strip().lower() + if not q: + return entries + return [e for e in entries + if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))] + + +def search_catalog(query: str) -> List[PluginCatalogEntry]: + return filter_entries(load_catalog(), query) + + +# ── Removed / blocklist ────────────────────────────────────────────────────── + +_SCP_URL_RE = re.compile(r"^(?:[^@/\s]+@)?([^:/\s]+):(?!//)(.+)$") # git@host:owner/repo + + +def _normalize_repo(url: str) -> str: + """Canonical ``host/path`` for a repo URL: scheme, user, ``www.``, ``.git`` and trailing slashes are + spelling, not identity — the kill list must match ``git@github.com:Evil/Bad.git`` when it names + ``https://github.com/evil/bad``.""" + from urllib.parse import urlsplit + text = url.strip() + scp = _SCP_URL_RE.match(text) + if scp: + host, path = scp.group(1), scp.group(2) + elif "://" in text: + parts = urlsplit(text) + host, path = parts.hostname or "", parts.path + else: + host, path = "", text + host = host.lower().removeprefix("www.") + path = path.strip("/").removesuffix(".git").rstrip("/").lower() + return f"{host}/{path}" if host else path + + +def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]: + """Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list. + + The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout + shipped must still block, and a stale live cache must not un-block an in-tree removal. + """ + if not name_or_repo: + return None + entries = load_removed_list(catalog_dir) + if catalog_dir is None: + entries = entries + live_removed_list() + return match_removed(name_or_repo, entries) + + +def resolved_removed_entries() -> List[RemovedEntry]: + """The full kill list (in-tree UNION live) in one resolution. Callers that match many candidates + — e.g. a plugins-hub rebuild annotating every installed plugin — resolve the list once instead + of paying a live-catalog fetch per candidate.""" + return load_removed_list() + live_removed_list() + + +def cached_removed_entries() -> List[RemovedEntry]: + """In-tree list UNION the last fetched live copy, with NO network round-trip — for the load-time and + ``enable`` checks that run in every process and must never block on a dead catalog host.""" + cached = _stale_live_cache(_live_cache_path()) or {} + return load_removed_list() + _removed_from_list(cached.get("removed")) + + +def match_removed( + candidate: str, entries: List[RemovedEntry] +) -> Optional[RemovedEntry]: + """One candidate against a pre-resolved kill list: exact name or normalized repo URL match.""" + if not candidate: + return None + text = candidate.strip() + text_repo = _normalize_repo(text) + for entry in entries: + if text == entry.name or ( + entry.repo and text_repo == _normalize_repo(entry.repo) + ): + return entry + return None + + +# ── Live catalog ───────────────────────────────────────────────────────────── + +def _live_cache_path() -> Path: + from hermes_constants import get_hermes_home + return get_hermes_home() / "cache" / "plugin-catalog.json" + + +def invalidate_live_cache_for_home(home: Path) -> None: + """Best-effort removal of the cached live catalog under *home* (any profile's home). + + ``hermes update`` drops it for every profile after the checkout changes: a snapshot fetched + before the bump would otherwise out-vote the newer in-tree catalog (pins the update just + changed, entries it just added) for the rest of :data:`LIVE_CATALOG_TTL_SECONDS` (#119340). + The next :func:`fetch_live_catalog` re-fetches the published doc, or falls back to the + in-tree catalog while the network is down — both newer than what was deleted. Safe when the + cache is absent (first run, other profiles that never opened the plugins hub). + """ + try: + (Path(home) / "cache" / "plugin-catalog.json").unlink(missing_ok=True) + except Exception as exc: + logger.debug("Plugin catalog: could not drop the live cache under %s: %s", home, exc) + + +# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one +# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per +# installed plugin), so the dashboard event loop stalled for minutes. +_live_fetch_failed_until = 0.0 + + +def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]: + """A previously fetched copy still beats the in-tree one when the network is down — for + :data:`LIVE_CATALOG_MAX_STALE_SECONDS`. Past that its pins may trail the checkout's own catalog + (a 90-day-old cache outranked a freshly updated in-tree pin), so the entries are dropped and the + caller falls back to in-tree; the removals are kept.""" + try: + if not cache.is_file(): + return None + data = json.loads(cache.read_text(encoding="utf-8-sig")) + if time.time() - cache.stat().st_mtime > LIVE_CATALOG_MAX_STALE_SECONDS: + data = {**data, "entries": []} + return data + except Exception: + return None + + +def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]: + """The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under + ``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall + back to the in-tree catalog. A failed network attempt is remembered for + :data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one + per call (``force`` bypasses both caches).""" + global _live_fetch_failed_until + cache = _live_cache_path() + try: + if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS: + return json.loads(cache.read_text(encoding="utf-8-sig")) + except Exception as exc: + logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc) + if not force and time.time() < _live_fetch_failed_until: + return _stale_live_cache(cache) + try: + import httpx + from hermes_constants import mkdir_under_hermes_home + from utils import atomic_write_text + + resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True) + resp.raise_for_status() + if len(resp.content) > _MAX_LIVE_BYTES: + raise ValueError("live catalog payload too large") + data = resp.json() + if not isinstance(data, dict) or not isinstance(data.get("entries"), list): + raise ValueError("unexpected live catalog payload") + mkdir_under_hermes_home(cache.parent) + # Atomic: a concurrent reader (gateway, TUI, a second CLI) must never see a half-written + # document, which would read as a fetch failure and start its own 60 s failure window. + atomic_write_text(cache, json.dumps(data), tmp_prefix=f"{cache.name}.tmp-") + return data + except Exception as exc: + logger.debug("Plugin catalog: live fetch failed: %s", exc) + _live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS + return _stale_live_cache(cache) + + +_in_tree_catalog_time: Optional[float] = -1.0 # -1 = not resolved yet; None = no usable in-tree time + + +def in_tree_catalog_time() -> Optional[float]: + """Commit time (epoch) of the last change to this checkout's ``plugin-catalog/``, or ``None`` when + the install is not a git checkout (a release/pip install cannot be newer than the published doc). + On a git checkout whose path history is unreadable without network — the treeless ``tree:0`` layout + ``hermes update`` produces — the newest checked-out catalog file's mtime stands in for it, so a + freshly updated checkout still outranks a doc fetched before the bump. Resolved once per process.""" + global _in_tree_catalog_time + if _in_tree_catalog_time != -1.0: + return _in_tree_catalog_time + root = get_catalog_dir().parent + resolved: Optional[float] = None + if (root / ".git").exists(): + try: + from hermes_cli._subprocess_compat import bounded_git_probe + + # Path history needs missing trees on tree:0 clones. The shared probe disables + # lazy fetch only for its child and bounds timeout cleanup on every platform. + out = bounded_git_probe( + ["git", "-C", str(root), "log", "-1", "--format=%ct", "--", "plugin-catalog"], + timeout=10, + ) + resolved = float(out) if out else None + except Exception as exc: + logger.debug("Plugin catalog: could not date the in-tree catalog: %s", exc) + if resolved is None: + # "History unreadable without network" must not collapse into "not a git checkout": + # ``None`` feeds the frozen-copy rule that defers to the live doc, which would let a + # cache fetched before ``hermes update`` re-pin the old sha on exactly the installs + # that just bumped it. A treeless clone still has its checked-out files — only the + # historical trees are missing — and their mtime dates the checkout that wrote them. + resolved = _catalog_worktree_mtime() + _in_tree_catalog_time = resolved + return resolved + + +def _catalog_worktree_mtime() -> Optional[float]: + """Newest mtime under the checkout's ``plugin-catalog/``: a no-network freshness signal for clones + whose path history is unreachable (treeless or offline). ``None`` when nothing is checked out.""" + try: + times = [p.stat().st_mtime for p in get_catalog_dir().rglob("*") if p.is_file()] + except OSError: + return None + return max(times) if times else None + + +def _live_generated_time(data: Dict[str, Any]) -> Optional[float]: + raw = data.get("generated_at") + if not isinstance(raw, str) or not raw: + return None + try: + from datetime import datetime, timezone + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).timestamp() + except ValueError: + return None + + +def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool: + """For one entry present in both sources with a different pin: the newer catalog wins. Newer is + decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve; + otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release + install's in-tree copy is frozen at release time).""" + if tree.sha == live.sha: + return False + if tree_is_newer is not None: + return tree_is_newer + if tree.version and live.version: + try: + from packaging.version import Version + return Version(tree.version) > Version(live.version) + except Exception: + return False + return False + + +def load_catalog_live() -> List[PluginCatalogEntry]: + """Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at + different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin, + a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`).""" + data = fetch_live_catalog() + if data is None: + return load_catalog() + entries = [e for i, raw in enumerate(data["entries"]) + if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None] + if not entries: + return load_catalog() + in_tree = {e.name: e for e in load_catalog()} + live_t, tree_t = _live_generated_time(data), in_tree_catalog_time() + tree_is_newer = (tree_t > live_t) if (live_t is not None and tree_t is not None) else None + raw_by_name = {str(raw.get("name")): raw for raw in data["entries"] if isinstance(raw, dict)} + return [in_tree[e.name] if e.name in in_tree and _prefer_in_tree_entry(in_tree[e.name], e, tree_is_newer) + else _with_curated_fields(e, in_tree.get(e.name), raw_by_name.get(e.name) or {}) + for e in entries] + + +# Curated display fields a published doc older than the field does not carry. ``generated_at`` is the +# docs build time, not the content time, so a rebuild of an older catalog outranks a checkout that added +# the field; a doc that has the key (even ``false``) decides. +_CURATED_FIELDS = ("onboarding", "title") + + +def _with_curated_fields(live: PluginCatalogEntry, tree: Optional[PluginCatalogEntry], raw: Dict[str, Any] + ) -> PluginCatalogEntry: + if tree is None or tree.sha != live.sha: + return live + missing = {key: getattr(tree, key) for key in _CURATED_FIELDS if key not in raw} + return dataclasses.replace(live, **missing) if missing else live + + +def live_removed_list() -> List[RemovedEntry]: + data = fetch_live_catalog() + return _removed_from_list(data.get("removed")) if data else [] + + +def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]: + return next((e for e in load_catalog_live() if e.name == name), None) + + +# ── Human summaries ────────────────────────────────────────────────────────── + +def entry_capability_summary(entry: PluginCatalogEntry) -> str: + """One paragraph shown at install/enable prompts: what the user is granting.""" + caps = entry.capabilities + parts = [f"{label} {', '.join(items)}" for label, items in ( + ("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks), + ("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items] + bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"] + if entry.description: + bits.append(entry.description) + bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.") + if entry.platforms: + bits.append(f"Platforms: {', '.join(entry.platforms)}.") + if entry.requires_hermes: + bits.append(f"Requires Hermes {entry.requires_hermes}.") + if entry.known_issues: + # #124058: informational — the catalog documents traps (unsupported + # install-method/mode combinations); surface them at install prompts + # without blocking the install. + bits.append(f"Known issues: {'; '.join(entry.known_issues)}.") + return " ".join(bits) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 912d932..10f86ba 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -564,6 +564,22 @@ def _install_metadata_path() -> Path: return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE +def _remove_plugin_tree(path: Path) -> None: + """Remove one validated plugin tree, including read-only Git objects on Windows.""" + import stat + target = path.resolve() + if _plugins_dir().resolve() not in target.parents: + raise ValueError("Plugin cleanup must stay strictly inside the plugin directory") + + def retry_readonly(function, failed_path, exc_info): + if os.name != "nt" or not isinstance(exc_info[1], PermissionError): + raise exc_info[1] + os.chmod(failed_path, stat.S_IRUSR | stat.S_IWUSR) + function(failed_path) + + shutil.rmtree(path, onerror=retry_readonly) + + def _read_install_metadata() -> dict[str, dict[str, object]]: """Read profile-local, non-secret plugin source metadata from disk.""" path = _install_metadata_path() @@ -867,7 +883,7 @@ def _install_plugin_core( _write_install_metadata(new_metadata) except Exception: if target.exists(): - shutil.rmtree(target) + _remove_plugin_tree(target) if replaced_existing and backup.exists(): os.replace(backup, target) if old_metadata: @@ -1208,7 +1224,7 @@ def _remove_plugin_core(target: Path) -> None: """Remove one plugin and its metadata without splitting their state.""" metadata = _read_install_metadata() if target.name not in metadata: - shutil.rmtree(target) + _remove_plugin_tree(target) return updated = dict(metadata) @@ -1230,7 +1246,7 @@ def _remove_plugin_core(target: Path) -> None: ) from restore_exc shutil.rmtree(staging, ignore_errors=True) raise - shutil.rmtree(staging) + _remove_plugin_tree(staging) def cmd_remove(name: str) -> None: @@ -2641,6 +2657,7 @@ def dashboard_install_plugin( *, force: bool, enable: bool, + ref: Optional[str] = None, ) -> dict[str, Any]: """Non-interactive install for the web dashboard. Returns a JSON-serializable dict.""" warnings: list[str] = [] @@ -2657,6 +2674,7 @@ def dashboard_install_plugin( target, installed_manifest, installed_name = _install_plugin_core( identifier, force=force, + ref=ref, ) except PluginScanBlocked as exc: findings = [] diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index b03f649..baf95b6 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -747,9 +747,11 @@ class FontSetBody(BaseModel): # --- from web_server.py (originally lines 19681-19684) --- class _AgentPluginInstallBody(BaseModel): - identifier: str + identifier: str = "" force: bool = False enable: bool = True + catalog_name: Optional[str] = None + ref: Optional[str] = None # --- from web_server.py (originally lines 19896-19898) --- @@ -763,4 +765,3 @@ class _PluginProvidersPutBody(BaseModel): class _PluginVisibilityBody(BaseModel): hidden: bool - diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bc0db86..0324ead 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -19070,16 +19070,28 @@ async def get_plugins_hub(request: Request): raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc +@app.get("/api/dashboard/plugins/catalog") +async def get_plugins_catalog(request: Request): + _require_token(request) + from hermes_cli.aiturk_plugin_catalog import catalog_payload + return await asyncio.to_thread(catalog_payload) + + @app.post("/api/dashboard/agent-plugins/install") async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody): _require_token(request) from hermes_cli.plugins_cmd import dashboard_install_plugin - result = dashboard_install_plugin( - body.identifier.strip(), - force=body.force, - enable=body.enable, - ) + if body.catalog_name: + from hermes_cli.aiturk_plugin_catalog import install_catalog_plugin + result = await asyncio.to_thread( + install_catalog_plugin, body.catalog_name, force=body.force, enable=body.enable, + ) + else: + result = await asyncio.to_thread( + dashboard_install_plugin, body.identifier.strip(), force=body.force, + enable=body.enable, ref=body.ref, + ) if not result.get("ok"): raise HTTPException( status_code=400, diff --git a/package-lock.json b/package-lock.json index b3e3f46..0ca8393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.19", + "version": "1.0.0-beta.20", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23", diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md new file mode 100644 index 0000000..ca23019 --- /dev/null +++ b/plugin-catalog/README.md @@ -0,0 +1,194 @@ +# Hermes Plugin Catalog + +Curated, Nous-approved Hermes plugins. Each YAML file in this directory +(except `removed.yaml`) is one catalog entry, discoverable via +`hermes plugins catalog` / `hermes plugins search` and installable with +`hermes plugins install `. + +## Admission policy + +Presence in this directory **is** the trust signal. The rules that keep it +meaningful: + + +1. **Human-merged gate.** Entries are added *only* via a PR to the + `hermes-agent` repository, reviewed and merged by a maintainer. There is + no self-serve registry, no automated ingestion. +2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character + commit SHA. Branches, tags, and short SHAs are rejected by the loader. + Installs clone the repository and check out exactly that commit. +3. **No self-updating code.** A listed plugin must not fetch and replace + its own files (in-app "check for updates", signed release downloaders, + remote `plugin.js` loaders). The exact SHA pin *is* the trust model; a + self-updater lets an installed copy move to a commit nobody reviewed. + Updates reach users only through a SHA-bump PR here plus + `hermes plugins update `. Keep the updater in the standalone + distribution if you want one; strip it from the catalog build. +4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff + (old SHA → new SHA) is re-reviewed like any other change — reviewers are + expected to look at the upstream commit range being adopted. +5. **Owner-or-major-contributor submissions, or a maintainer-curated sweep.** + An entry may be submitted by the plugin repository's owner or a major + contributor to it; drive-by submissions of third-party repos are declined. + Hermes maintainers may also add entries in batches from a reviewed sweep + of community plugins (every pin validated and scanned at the pinned + commit, self-updater and credential-store checks run, English-first UI). + Authors of swept-in entries keep control: a PR from the owner adjusting + or removing their entry is accepted on request, and SHA bumps stay + owner-or-maintainer PRs under rule 4. +6. **Declared capabilities must match reality.** The `capabilities:` block + (tools, hooks, middleware, env vars) must match what the plugin actually + registers at the pinned commit. Validation fails the entry otherwise — + undeclared capability creep is treated as a security issue. +7. **The install scanner runs at admission.** `hermes plugins validate` includes + the `security scan` check: `dangerous` fails the entry; `caution` findings + appear as warnings in the CI log and the reviewer reads them before merging. + In exchange, installs at the pinned SHA accept `caution` without a prompt + (`dangerous` still blocks). Review the warnings; do not merge past them. +8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs + in the Desktop renderer with the app's full authority (the loader isolates + errors, not capabilities), so a listed one may only use the plugin SDK: + no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`), + no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk` + / `react` (app bundle chunks, blob or http URLs included), no script-tag + injection, no reaching into the app's internal stores or its own markup + (querying `data-slot` / `data-tour` / `data-sidebar` / `data-testid` + elements from `document`, or a `document.body` MutationObserver, to restyle, + hide, click or rewrite core UI). `hermes plugins validate` refuses these at + admission (`desktop surface` check); a plugin that needs a capability the + SDK lacks asks for an SDK hook instead of patching around it. +9. **No runtime overrides of Hermes core.** A listed plugin extends Hermes only + through public surfaces: hooks, middleware, provider profiles and the + other `register_*` APIs, and Desktop SDK slots and routes. It must not + replace, wrap or rebind core functions, methods, module attributes or + private dicts in place (`AIAgent. = ...`, `setattr(server, ...)`, + `sys.modules[...]`, writes into a core module's tables). Two plugins + patching the same seam silently break each other, and every core release + can break both. `hermes plugins validate` refuses these at admission (`no + core override` check). If the hook you need does not exist, open an issue + describing it: we would rather add the seam than list a patch. +10. **Dependency security policy is the plugin's.** Hermes's 14-day + `exclude-newer` quarantine covers Hermes's own dependencies only; a plugin's + `python_dependencies` / `pyproject.toml` install under the plugin's policy + (no quarantine, still inside Hermes's core constraints). Reviewers read the + dependency list at the pinned SHA: bare floors (`>=X` with no upper bound) + and floors on the newest release get a request for the oldest + API-compatible floor plus an upper bound, and authors are strongly + recommended to run their own release quarantine (`uv --exclude-newer` in + their CI) — see the developer guide's *Dependency security policy*. A + recent floor alone is not grounds to hold an entry. + +11. **Credentials stay with their owner.** A plugin reads the credentials it is + configured with: the env vars in `requires_env` and its own `config_schema` + secrets. Reading another tool's login (a vendor CLI's token file, a browser + profile) must be disclosed in the PR and is a trust-tier call for a + maintainer. Refreshing, rotating or writing another client's OAuth tokens, or + presenting itself as another vendor's client, is not admitted without an + explicit maintainer ruling; a read-only build is the usual way through. +12. **Approvals and unattended runs are respected.** A plugin never routes around + Hermes's approval system: no auto-approving, no disabling guards, and no + spawning Hermes or shell children that inherit YOLO or non-interactive mode + to run commands nobody approved. Anything that waits for a person (a prompt, + an OAuth browser flow) fails cleanly or times out under cron, the messaging + gateway and other unattended runs instead of hanging the agent. +13. **Risky behaviour is disclosed.** What a user would want to know before + installing goes in the PR description and the plugin's README: network calls + to third-party services, reads outside the plugin's own data, shell commands, + long-running background processes, stored credentials. Telemetry and usage + reporting are opt-in. Reviewers summarise these as disclosure lines on the + entry PR; undisclosed behaviour found in review is a request for changes. +14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor + (`">=0.21.5"`), never a CalVer date, and never newer than the current release + (the loader skips the plugin otherwise). `version` matches the pinned code, + and Python dependencies resolve under Hermes's core constraints + (`hermes plugins validate --install-deps` is what CI runs). +15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a + PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins + are not listed under Nous branding. +16. **One listing per plugin lineage.** A fork of a listed community plugin is + listed only when it is materially different from the original: a different + transport or architecture, or capability the original lacks and its author + declined or has not answered a PR for 30 days. Improvements to a listed plugin + go upstream as a PR to its author. A fork that renames, rebrands or adds small + changes is declined in favour of the original. A listed fork names its origin + in its disclosure line (`Derived from `). + + +The step-by-step submission guide, with the same rules and what reviewers check, +lives at +[Submitting to the plugin catalog](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/catalog-submission). + +## Entry schema + +```yaml +name: example-plugin # [a-z0-9_-]{1,64}, the catalog key +repo: https://github.com/owner/repo # https:// only +sha: <40-hex commit sha> # mandatory exact pin +subdir: "" # optional; plain relative path inside the repo + # ([A-Za-z0-9._/-]+ only: no '..', '.', empty + # segments, absolute, or backslash forms) +description: One-line description. +maintainer: OwnerName +tier: official # official | community (default community) +category: memory # desktop | memory | platform | web | tools | voice | automation | models | general + # (default desktop) — the shelf the entry sits on at /docs/plugins +requires_hermes: ">=0.19" # optional +docs_url: "" # optional +version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234" +image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g. + # https://raw.githubusercontent.com/owner/repo//docs/banner.png +screenshots: [] # optional, up to 6 https images on a GitHub host; gallery on /docs/plugins/ +readme: true # optional, default true; the README at the PINNED SHA renders on /docs/plugins/ +platforms: [] # optional, e.g. [linux, macos]; empty = all +title: "" # optional human name on cards ("NVIDIA App"); default = name +onboarding: false # optional; offered on the Desktop onboarding card (official entries only) +known_issues: [] # optional strings shown at the install prompt; informational, never blocks +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] +``` + +`version`, `image`, `screenshots` and `readme` are cosmetic: none is parsed or +used to pick what installs. The sha stays the release; bump `version` in the +same PR that bumps `sha` so the label on the card matches the code. Images must +live on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com` +so the Desktop catalog and the docs site never fetch from third-party hosts; +pin the raw URL to the entry's commit and the picture is as immutable as the +code. + +Every entry gets a page at `https://hermes-agent.nousresearch.com/docs/plugins/` +and every maintainer a page at `/docs/plugins/by/`, both generated +from these files at docs build time. `screenshots:` fills the page's gallery; +the build fetches the README (from `subdir` if set, else the repo root) **at the +pinned sha** — GitHub and GitLab repos, on by default, `readme: false` opts out — and renders it +through an allowlist (raw HTML dropped, links and images resolved against the +pinned tree). The page therefore shows the README the reviewer read, and it +changes only when a reviewed re-pin lands. + +## removed.yaml — the blocklist + +When an entry is pulled from the catalog for security or policy reasons, it +is recorded in `removed.yaml` with a reason and date. The installer refuses +to install anything matching a removed entry's name or repo URL, so a +malicious plugin cannot be re-installed from a stale identifier after +removal. A plugin that lands on the list after a user installed it stops +updating, cannot be enabled and is refused at load time. Removals, like +additions, land via reviewed PRs. + +Delisting is different from removal: an entry that is unmaintained, superseded, squatting a +name it is not affiliated with, or no longer meets a rule is deleted from the catalog (plain +file removal, users who already installed it are unaffected). It is welcome back once the +problem is fixed; a name squatter comes back under a distinct name. When a new rule affects +entries that are already listed, their authors get an issue on their repository explaining +the change and time to update before anything is delisted. + +## Names + +The catalog key and the manifest `name:` are what users search, install, and — for memory +providers — put in `memory.provider`. A `memory` / `exclusive` entry must not reuse the name of +another provider or of a well-known upstream project it is not affiliated with: two providers +registering the same `register_memory_provider` name make `memory.provider` ambiguous +(whichever loads last wins). Reviewers check the registered provider name, not just the file +name, and the affiliated project gets the bare key. diff --git a/plugin-catalog/blender.yaml b/plugin-catalog/blender.yaml new file mode 100644 index 0000000..209f386 --- /dev/null +++ b/plugin-catalog/blender.yaml @@ -0,0 +1,23 @@ +name: blender +repo: https://github.com/NousResearch/hermes-plugin-blender +sha: d1929b593bddd2241a8939fb355e0a69b8e077af +version: "0.1.0" +description: >- + Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3, + with a Blender workflow skill. Requires Blender 5.1+, its separately + installed MCP add-on, Git and uv. First startup downloads Python dependencies; + prepare the uv environment before connecting. Disclosure: tools execute + arbitrary Python in Blender with the user's privileges. No automatic + application/version/liveness gate; server discovery does not prove scene readiness. +title: Blender +maintainer: NousResearch +tier: official +category: tools +docs_url: https://github.com/NousResearch/hermes-plugin-blender#readme +platforms: [windows, macos, linux] +onboarding: true +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/claude-subscription-directsdk.yaml b/plugin-catalog/claude-subscription-directsdk.yaml new file mode 100644 index 0000000..2421b8f --- /dev/null +++ b/plugin-catalog/claude-subscription-directsdk.yaml @@ -0,0 +1,22 @@ +name: claude-subscription-directsdk +repo: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk +sha: 31b591fd04737a7807183f3d7f3d389b19f94687 +description: Experimental model provider that runs Hermes turns on a Claude Pro/Max subscription through + the official Claude Code CLI (the Agent SDK path), so no separate API key or per-token bill. Requires + the claude CLI installed and logged in; the plugin checks for it at load, in hermes model, in the pickers + and before every request, and refuses cleanly with the install hint when it is missing. Disclosure — every + turn is billed against your Claude subscription's Agent SDK allowance, and the provider inherits whatever + account claude is logged into. +maintainer: NousResearch +tier: official +category: models +requires_hermes: ">=0.21.4" +docs_url: https://github.com/NousResearch/hermes-plugin-claude-subscription-directsdk#readme +version: "0.3.2" +platforms: [] +image: https://raw.githubusercontent.com/NousResearch/hermes-plugin-claude-subscription-directsdk/31b591fd04737a7807183f3d7f3d389b19f94687/assets/card.png +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/hermes-memory-wiki.yaml b/plugin-catalog/hermes-memory-wiki.yaml new file mode 100644 index 0000000..62951f1 --- /dev/null +++ b/plugin-catalog/hermes-memory-wiki.yaml @@ -0,0 +1,15 @@ +name: hermes-memory-wiki +repo: https://github.com/NousResearch/hermes-memory-wiki +sha: 9bc3913b8474eaf4d7eec32e97af4d77957c36df +description: 'Memory Wiki dashboard tab: browsable subject pages and daily logs derived from local session + history, plus a read-only Persistent Memory (MEMORY.md / USER.md) audit panel.' +maintainer: NousResearch +tier: official +requires_hermes: ">=0.21" +docs_url: https://github.com/NousResearch/hermes-memory-wiki#readme +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/hermes-telegram-business.yaml b/plugin-catalog/hermes-telegram-business.yaml new file mode 100644 index 0000000..dfc92f2 --- /dev/null +++ b/plugin-catalog/hermes-telegram-business.yaml @@ -0,0 +1,15 @@ +name: hermes-telegram-business +repo: https://github.com/NousResearch/hermes-telegram-business +sha: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757 +description: 'Observe-with-approval Telegram Business Mode (secretary bot): every drafted customer reply + needs owner approval before it is sent.' +maintainer: NousResearch +tier: official +category: platform +docs_url: https://github.com/NousResearch/hermes-telegram-business +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/homeassistant.yaml b/plugin-catalog/homeassistant.yaml new file mode 100644 index 0000000..0cac985 --- /dev/null +++ b/plugin-catalog/homeassistant.yaml @@ -0,0 +1,23 @@ +name: homeassistant +repo: https://github.com/NousResearch/hermes-homeassistant +sha: ba30cb0cf86c52bdb5cde98974bc062e97966529 +description: 'Home Assistant gateway platform (state-change events in, persistent notifications and cron notify + delivery out) plus the homeassistant toolset (ha_list_entities, ha_get_state, ha_list_services, ha_call_service). + Formerly built into Hermes core; existing users are migrated automatically.' +maintainer: NousResearch +tier: official +category: platform +requires_hermes: ">=0.21.5" +version: "2.0.1" +docs_url: https://github.com/NousResearch/hermes-homeassistant +platforms: [] +capabilities: + provides_tools: + - ha_list_entities + - ha_get_state + - ha_list_services + - ha_call_service + provides_hooks: [] + provides_middleware: [] + requires_env: + - HASS_TOKEN diff --git a/plugin-catalog/nvidia-app.yaml b/plugin-catalog/nvidia-app.yaml new file mode 100644 index 0000000..c4b2249 --- /dev/null +++ b/plugin-catalog/nvidia-app.yaml @@ -0,0 +1,24 @@ +name: nvidia-app +repo: https://github.com/NousResearch/hermes-nvidia +sha: 65bb8d89263f920e8599432823d2d4e05cc113e3 +subdir: nvidia-app +description: "NVIDIA App control through its local MCP server: overlay capture and state, driver status + and release notes, game optimization, and app launch. Requires NVIDIA App 11.0 or newer on Windows; + the install is refused when the app is absent, and the tools appear while the app's service is running. + The plugin reads the endpoint and token NVIDIA App publishes in its own McpServer server.json on every + connection, so a changed port or token needs no reconfiguration. Disclosure: the tools read and change + NVIDIA App settings on this machine; nothing leaves loopback." +title: NVIDIA App +maintainer: NousResearch +tier: official +category: tools +requires_hermes: ">=0.21.4" +docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-app +version: "0.1.0" +platforms: [windows] +onboarding: true +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/nvidia-broadcast.yaml b/plugin-catalog/nvidia-broadcast.yaml new file mode 100644 index 0000000..ef3d038 --- /dev/null +++ b/plugin-catalog/nvidia-broadcast.yaml @@ -0,0 +1,24 @@ +name: nvidia-broadcast +repo: https://github.com/NousResearch/hermes-nvidia +sha: 65bb8d89263f920e8599432823d2d4e05cc113e3 +subdir: nvidia-broadcast +description: "NVIDIA Broadcast control through its local MCP gateway: camera, microphone and speaker + effects, Studio Voice profiles, device selection, camera resolution, and effects on local media files. + Requires NVIDIA Broadcast 2.3 or newer on Windows (2.2 and older have no gateway); the install is + refused when the app is absent, and the tools appear while NVIDIA Broadcast is running in the user's + desktop session. Disclosure: the tools change live audio and video effects on this machine; nothing + leaves loopback." +title: NVIDIA Broadcast +maintainer: NousResearch +tier: official +category: tools +requires_hermes: ">=0.21.4" +docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-broadcast +version: "0.1.0" +platforms: [windows] +onboarding: true +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/removed.yaml b/plugin-catalog/removed.yaml new file mode 100644 index 0000000..4cf9db8 --- /dev/null +++ b/plugin-catalog/removed.yaml @@ -0,0 +1,12 @@ +# Blocklist for plugins pulled from the catalog for security or policy +# reasons. The installer refuses to install anything whose name or repo URL +# matches an entry here (unless the caller explicitly bypasses the check). +# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs, +# same as additions. +# +# Name only. Do not set repo: the replacement entry is the same repository, +# and a repo match would refuse that install too. +removed: +- name: or-picker + reason: "Folded into openrouter-picker. Installing the provider alone left the picker broken. Install openrouter-picker." + date: "2026-09-28" diff --git a/plugin-catalog/snyk.yaml b/plugin-catalog/snyk.yaml new file mode 100644 index 0000000..b0f54b4 --- /dev/null +++ b/plugin-catalog/snyk.yaml @@ -0,0 +1,17 @@ +name: snyk +repo: https://github.com/NousResearch/hermes-plugin-snyk +sha: 2a41a07f81e45125bf82a19af1b13396ace4b81f +description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs with Snyk through its + first-party MCP server (pinned snyk@1.1306.0 over npx stdio, CLI analytics disabled), with the + snyk-security-scan workflow skill bundled. Portable Agent Plugins v1 package (mcp.json + skills/); + needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).' +maintainer: NousResearch +tier: official +category: tools +docs_url: https://github.com/NousResearch/hermes-plugin-snyk +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/touchdesigner.yaml b/plugin-catalog/touchdesigner.yaml new file mode 100644 index 0000000..f5bbab4 --- /dev/null +++ b/plugin-catalog/touchdesigner.yaml @@ -0,0 +1,16 @@ +name: touchdesigner +repo: https://github.com/NousResearch/hermes-plugin-touchdesigner +sha: 88a7a7884e5eda33cfe4b1dd5e69df7cb15d5898 +description: 'Drive a live TouchDesigner session through the twozero MCP server (Streamable HTTP on + 127.0.0.1:40404), with the touchdesigner-mcp workflow skill bundled. Portable Agent Plugins v1 package + (mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.' +maintainer: NousResearch +tier: official +category: tools +docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/tests/hermes_cli/test_aiturk_plugin_catalog.py b/tests/hermes_cli/test_aiturk_plugin_catalog.py new file mode 100644 index 0000000..f5ad370 --- /dev/null +++ b/tests/hermes_cli/test_aiturk_plugin_catalog.py @@ -0,0 +1,56 @@ +from hermes_cli import aiturk_plugin_catalog as adapter +from hermes_cli import plugin_catalog as catalog +from hermes_cli import plugins_cmd + +SHA = "a" * 40 + + +def entry(**extra): + return catalog.entry_from_mapping(dict(name="demo", repo="https://github.com/owner/demo", + sha=SHA, description="test", maintainer="owner", tier="community", **extra), "test") + + +def test_install_uses_catalog_pin(monkeypatch): + monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry()) + monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: []) + seen = [] + monkeypatch.setattr(plugins_cmd, "dashboard_install_plugin", lambda *args, **kwargs: seen.append((args, kwargs)) or {"ok": True}) + assert adapter.install_catalog_plugin("demo")["ok"] + assert seen == [(("https://github.com/owner/demo",), {"force": False, "enable": True, "ref": SHA})] + + +def test_missing_or_incompatible_catalog_entry_is_rejected(monkeypatch): + monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: None) + assert not adapter.install_catalog_plugin("missing")["ok"] + monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: []) + monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(requires_hermes=">=999.0")) + assert not adapter.install_catalog_plugin("demo")["ok"] + monkeypatch.setattr(catalog, "get_live_catalog_entry", lambda name: entry(platforms=["nonexistent"])) + assert not adapter.install_catalog_plugin("demo")["ok"] + + +def test_payload_tracks_installed_revision(monkeypatch): + monkeypatch.setattr(catalog, "load_catalog_live", lambda: [entry()]) + monkeypatch.setattr(catalog, "resolved_removed_entries", lambda: []) + monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: [("demo",)]) + monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {"demo": {"revision": "b" * 40}}) + row = adapter.catalog_payload()["entries"][0] + assert row["installed"] and row["update_available"] and row["sha"] == SHA + + +def test_catalog_http_auth_and_install_contract(monkeypatch): + from fastapi.testclient import TestClient + from hermes_cli import web_server as web + client = TestClient(web.app) + assert client.get("/api/dashboard/plugins/catalog").status_code in (401, 403) + client.headers[web._SESSION_HEADER_NAME] = web._SESSION_TOKEN + monkeypatch.setattr(adapter, "catalog_payload", lambda: {"entries": [entry().to_dict()], "removed": []}) + response = client.get("/api/dashboard/plugins/catalog") + assert response.status_code == 200 and response.json()["entries"][0]["sha"] == SHA + seen = [] + monkeypatch.setattr(adapter, "install_catalog_plugin", lambda name, **kwargs: seen.append(name) or {"ok": True, "after_install_path": "private"}) + monkeypatch.setattr(web, "_get_dashboard_plugins", lambda **kwargs: []) + monkeypatch.setattr(web, "_invalidate_plugins_hub_cache", lambda: None) + response = client.post("/api/dashboard/agent-plugins/install", json={"catalog_name": "demo", "identifier": "https://untrusted.invalid/repo"}) + assert response.status_code == 200 and seen == ["demo"] + assert "after_install_path" not in response.json() diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py new file mode 100644 index 0000000..89d538d --- /dev/null +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -0,0 +1,300 @@ +"""Plugin catalog contracts (hermes_cli/plugin_catalog.py): the in-tree seed is valid, bad entries are +skipped not raised, kill-list matching is name-or-repo, and the live catalog degrades to in-tree.""" + +from __future__ import annotations + +import json +import os + +import pytest + +import yaml + +from hermes_cli import plugin_catalog as pc + +SHA = "38fe0fb53eff98d477f807432e965429e665ca33" + + +def _entry(name="good-plugin", **over): + data = {"name": name, "repo": "https://github.com/owner/repo", "sha": SHA, "description": "d", + "maintainer": "owner", "tier": "community", "capabilities": {"provides_tools": ["t1"]}} + data.update(over) + return data + + +def test_shipped_catalog_entries_are_all_valid_and_pinned(): + """Every file in plugin-catalog/ (minus removed.yaml) must parse — a dropped entry is a silent + shipping regression the admission CI only catches on changed files.""" + root = pc.get_catalog_dir() + files = [p for p in root.glob("*.yaml") if p.name != "removed.yaml"] + entries = pc.load_catalog() + assert len(entries) == len(files) >= 1 + assert all(pc._SHA_RE.match(e.sha) and e.repo.startswith("https://") for e in entries) + assert all(e.install_identifier.startswith(e.repo) for e in entries) + + +def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path): + """``category`` is the browse shelf: absent → ``desktop``; a value outside CATALOG_CATEGORIES is an + invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site.""" + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat"))) + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory"))) + (tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory"))) + entries = {e.name: e for e in pc.load_catalog(tmp_path)} + assert set(entries) == {"no-cat", "mem"} + assert entries["no-cat"].category == "desktop" and entries["mem"].category == "memory" + assert entries["mem"].to_dict()["category"] == "memory" + + +def test_version_and_image_are_cosmetic_and_offhost_images_are_dropped(tmp_path): + """``version``/``image`` label the pin, they never gate it: a bad value is dropped with a warning and + the entry survives; an image off GitHub is dropped because the Desktop browser must never fetch + from third-party hosts.""" + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"))) + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("offhost", version="1.4.0 beta", image="https://evil.example/x.png"))) + entries = {e.name: e for e in pc.load_catalog(tmp_path)} + assert set(entries) == {"labelled", "offhost"} + assert entries["labelled"].version == "1.4.0" and entries["labelled"].image == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png" + assert entries["offhost"].version == "" and entries["offhost"].image == "" + assert entries["labelled"].to_dict()["version"] == "1.4.0" + + +def test_screenshots_and_readme_are_parsed_and_readme_defaults_on(tmp_path): + shot = "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/docs/1.png" + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("paged", screenshots=[shot, "https://evil.example/x.png"], readme=True))) + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("plain", readme=False))) + (tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("bare"))) + entries = {e.name: e for e in pc.load_catalog(tmp_path)} + assert entries["paged"].screenshots == [shot] and entries["paged"].readme is True + assert entries["plain"].screenshots == [] and entries["plain"].readme is False + assert entries["bare"].readme is True + assert entries["paged"].to_dict()["screenshots"] == [shot] and entries["paged"].to_dict()["readme"] is True + + +def test_invalid_entries_are_skipped_not_raised(tmp_path): + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok", description="café")), encoding="utf-8-sig") + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123"))) + (tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("http-repo", repo="http://x/y"))) + (tmp_path / "d.yaml").write_text(yaml.safe_dump(_entry("Bad Name"))) + (tmp_path / "e.yaml").write_text("- not\n- a mapping\n") + entries = pc.load_catalog(tmp_path) + assert [e.name for e in entries] == ["ok"] + assert entries[0].description == "café" + + +def test_find_removed_matches_name_or_normalized_repo(tmp_path): + (tmp_path / "removed.yaml").write_text(yaml.safe_dump({"removed": [ + {"name": "evil", "repo": "https://github.com/x/evil.git", "reason": "malware", "date": "2026-01-01"}]})) + assert pc.find_removed("evil", tmp_path).reason == "malware" + assert pc.find_removed("https://github.com/x/EVIL/", tmp_path) is not None + assert pc.find_removed("https://github.com/x/fine", tmp_path) is None + # Scheme/host/user spellings are not identity: every git way of naming the repo is blocked. + for spelling in ("git@github.com:x/evil.git", "ssh://git@github.com/x/evil", "http://github.com/x/evil", + "https://www.github.com/x/evil/", "git://github.com/x/evil.git"): + assert pc.find_removed(spelling, tmp_path) is not None, spelling + assert pc.find_removed("git@gitlab.com:x/evil.git", tmp_path) is None # different host stays distinct + + +@pytest.mark.parametrize("stale", [False, True]) +def test_live_catalog_falls_back_to_in_tree_and_unions_removals(tmp_path, monkeypatch, stale): + """Offline live pins expire, but a cached removal keeps blocking installs.""" + import httpx + + requests = [] + get = httpx.get + + def record_get(*args, **kwargs): + requests.append(args) + return get(*args, **kwargs) + + monkeypatch.setattr(httpx, "get", record_get) + cache = tmp_path / "cache" / "plugin-catalog.json" + monkeypatch.setattr(pc, "_live_cache_path", lambda: cache) + monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # unreachable + assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()] + + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps({"entries": [_entry("live-only", description="café")], + "removed": [{"name": "pulled-live", "reason": "cve"}]}, + ensure_ascii=False), encoding="utf-8-sig") + if stale: + os.utime(cache, (1, 1)) + requests.clear() + # The unreachable fetch above armed the failure window; a stale cache must still + # trigger a real retry once that window has passed. + monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0) + entries = pc.load_catalog_live() + if stale: + assert [e.name for e in entries] == [e.name for e in pc.load_catalog()] + else: + assert [e.name for e in entries] == ["live-only"] + assert entries[0].description == "café" + assert bool(requests) is stale + assert pc.find_removed("pulled-live").reason == "cve" + cache.write_text("{", encoding="utf-8-sig") + assert [e.name for e in pc.load_catalog_live()] == [e.name for e in pc.load_catalog()] + + +def _fresh_cache(tmp_path, monkeypatch, doc: dict): + cache = tmp_path / "cache" / "plugin-catalog.json" + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(doc)) + monkeypatch.setattr(pc, "_live_cache_path", lambda: cache) + monkeypatch.setattr(pc, "LIVE_CATALOG_URL", "http://127.0.0.1:9/nope") # never reached / unreachable + return cache + + +def test_newer_in_tree_pin_outranks_a_fresh_live_cache(tmp_path, monkeypatch): + """Right after `hermes update` bumps an in-tree pin, a live cache fetched BEFORE the bump must not + re-install the old sha: for the same entry the newer catalog wins (checkout catalog commit time vs + the doc's generated_at). Live-only entries survive; an older checkout still defers to the doc.""" + old, new = SHA, "a" * 40 + _fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", + "entries": [_entry("shared", sha=old), _entry("live-only")], "removed": []}) + monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")]) + bump_time = pc._live_generated_time({"generated_at": "2026-09-22T10:00:00Z"}) + 3600 + monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time) + by_name = {e.name: e for e in pc.load_catalog_live()} + assert by_name["shared"].sha == new and "live-only" in by_name + # Control: a checkout whose catalog predates the doc takes the live pin. + monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: bump_time - 7200) + assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old + + +def test_live_cache_write_never_truncates_the_previous_copy(tmp_path, monkeypatch): + """The cache is replaced atomically: a reader racing the writer (or a write that dies half-way) + sees the whole previous document, never a truncated one.""" + cache = _fresh_cache(tmp_path, monkeypatch, {"entries": [_entry("previous")], "removed": []}) + previous = cache.read_text() + + class _Resp: + content = json.dumps({"entries": [_entry("fresh")], "removed": []}).encode() + def raise_for_status(self): pass + def json(self): return json.loads(self.content) + + import httpx + import utils + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp()) + pc._live_fetch_failed_until = 0.0 + + def _dies(*_a, **_k): + raise OSError("disk full") + + monkeypatch.setattr(utils, "atomic_replace", _dies) + pc.fetch_live_catalog(force=True) # the failed write is swallowed like any other fetch failure + assert cache.read_text() == previous + assert not list(cache.parent.glob("*.tmp*")) # no leftover temp file either + + +def test_curated_fields_the_published_doc_lacks_come_from_the_checkout(tmp_path, monkeypatch): + """The docs build stamps generated_at at build time, so a doc rebuilt from an older catalog is "newer" + than a checkout that just added `onboarding`. At the same pin, a curated field the doc does not carry + comes from the checkout; a doc that carries it (even false) decides, and a different pin never merges.""" + live = [_entry("same"), {**_entry("says-no"), "onboarding": False}, _entry("repinned", sha="b" * 40)] + _fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", "entries": live, "removed": []}) + tree = [pc.entry_from_mapping({**_entry(n), "onboarding": True, "title": "T"}, n) for n in ("same", "says-no", "repinned")] + monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: tree) + monkeypatch.setattr(pc, "in_tree_catalog_time", lambda: 0.0) # checkout older than the doc + by_name = {e.name: e for e in pc.load_catalog_live()} + assert (by_name["same"].onboarding, by_name["same"].title) == (True, "T") + assert by_name["says-no"].onboarding is False and by_name["says-no"].title == "T" + assert by_name["repinned"].onboarding is False and by_name["repinned"].sha == "b" * 40 + + +def test_in_tree_catalog_time_does_not_lazy_fetch_on_treeless_clones(tmp_path, monkeypatch): + """``in_tree_catalog_time`` dates the checkout with a pathspec'd ``git log``; the pathspec makes git + open every commit's tree. On a treeless (``tree:0``) partial clone — the layout ``hermes update`` + produces — none of those trees exist locally, so an unrestricted probe lazy-fetches each one against + the remote: measured 40-80 s per inventory request, past the desktop's 30s RPC limit (#125683). + The probe must scope ``GIT_NO_LAZY_FETCH`` to itself so missing objects fail fast into the + worktree-mtime fallback — never into network work — while a full checkout keeps resolving its time. + This clone is never checked out, so there is no mtime to fall back to either: ``None`` all the way.""" + import shutil + import subprocess as sp + if shutil.which("git") is None: + pytest.skip("git not available") + src = tmp_path / "src" + (src / "plugin-catalog").mkdir(parents=True) + (src / "plugin-catalog" / "a.yaml").write_text("name: a\n") + env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} + sp.run(["git", "init", "-q"], cwd=src, check=True, env=env) + sp.run(["git", "add", "-A"], cwd=src, check=True, env=env) + sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env) + sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env) + + def local_object_types(repo): + out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo, + capture_output=True, text=True, env=env) + return {line.split()[1] for line in out.stdout.splitlines() if line} + + dst = tmp_path / "treeless" + # file:// (not a plain path) so the clone goes through upload-pack and honors the filter. + sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)], + check=True, env=env) + if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect + pytest.skip("file:// transport did not honor --filter=tree:0") + + monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog") + monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh in this test + assert pc.in_tree_catalog_time() is None + assert "tree" not in local_object_types(dst) # the probe lazy-fetched nothing + + monkeypatch.setattr(pc, "get_catalog_dir", lambda: src / "plugin-catalog") + monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) + assert pc.in_tree_catalog_time() is not None # a full checkout still resolves its commit time + + +def test_treeless_checkout_still_outranks_a_doc_fetched_before_the_bump(tmp_path, monkeypatch): + """On the treeless layout the history probe fails fast, and ``None`` would feed the frozen-copy + rule that lets the live doc win — re-pinning the old sha on exactly the checkouts ``hermes + update`` just bumped. The checked-out files' mtime must stand in for the unreadable commit time + (fresh checkout ⇒ in-tree pin wins; catalog files older than the doc ⇒ live pin wins), with no + tree lazy-fetch anywhere (#125716 review).""" + import shutil + import subprocess as sp + if shutil.which("git") is None: + pytest.skip("git not available") + src = tmp_path / "src" + (src / "plugin-catalog").mkdir(parents=True) + (src / "plugin-catalog" / "a.yaml").write_text("name: a\n") + env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} + sp.run(["git", "init", "-q"], cwd=src, check=True, env=env) + sp.run(["git", "add", "-A"], cwd=src, check=True, env=env) + sp.run(["git", "commit", "-qm", "seed"], cwd=src, check=True, env=env) + (src / "plugin-catalog" / "a.yaml").write_text("name: a2\n") + sp.run(["git", "add", "-A"], cwd=src, check=True, env=env) + sp.run(["git", "commit", "-qm", "bump"], cwd=src, check=True, env=env) + sp.run(["git", "config", "uploadpack.allowFilter", "true"], cwd=src, check=True, env=env) + + def local_object_types(repo): + out = sp.run(["git", "cat-file", "--batch-all-objects", "--batch-check"], cwd=repo, + capture_output=True, text=True, env=env) + return {line.split()[1] for line in out.stdout.splitlines() if line} + + dst = tmp_path / "treeless" + # file:// (not a plain path) so the clone goes through upload-pack and honors the filter. + sp.run(["git", "clone", "-q", "--filter=tree:0", "--no-checkout", src.as_uri(), str(dst)], + check=True, env=env) + if "tree" in local_object_types(dst): # transport ignored the filter: no missing objects to protect + pytest.skip("file:// transport did not honor --filter=tree:0") + # Checking out only fetches HEAD's trees; the seed commit's tree stays missing, so the + # pathspec'd probe keeps failing — while the worktree files (and their mtimes) land. + sp.run(["git", "-C", str(dst), "checkout", "-q"], check=True, env=env) + probe = sp.run(["git", "-C", str(dst), "log", "-1", "--format=%ct", "--", "plugin-catalog"], + capture_output=True, env={**env, "GIT_NO_LAZY_FETCH": "1"}) + assert probe.returncode != 0 # the probe really is blind on this layout, like a real update + + old, new = SHA, "a" * 40 + _fresh_cache(tmp_path, monkeypatch, {"generated_at": "2026-09-22T10:00:00Z", + "entries": [_entry("shared", sha=old)], "removed": []}) + monkeypatch.setattr(pc, "load_catalog", lambda catalog_dir=None: [pc.entry_from_mapping(_entry("shared", sha=new), "t")]) + monkeypatch.setattr(pc, "get_catalog_dir", lambda: dst / "plugin-catalog") + monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) # resolve afresh: probe fails, mtime stands in + assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == new + + # Control: catalog files older than the doc mean the checkout is the stale side — live pin wins. + for p in (dst / "plugin-catalog").rglob("*"): + os.utime(p, (0, 0)) + monkeypatch.setattr(pc, "_in_tree_catalog_time", -1.0) + assert {e.name: e.sha for e in pc.load_catalog_live()}["shared"] == old diff --git a/tests/hermes_cli/test_plugin_install_ref.py b/tests/hermes_cli/test_plugin_install_ref.py index f7f1dbe..c331af8 100644 --- a/tests/hermes_cli/test_plugin_install_ref.py +++ b/tests/hermes_cli/test_plugin_install_ref.py @@ -358,7 +358,8 @@ def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_p target, _manifest, _name = _install_plugin_core( repo.as_uri(), force=False, ref=old_sha ) - shutil.rmtree(target) + from hermes_cli.plugins_cmd import _remove_plugin_tree + _remove_plugin_tree(target) target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False) diff --git a/tests/tui_gateway/test_mobile_subagents.py b/tests/tui_gateway/test_mobile_subagents.py new file mode 100644 index 0000000..782a8cb --- /dev/null +++ b/tests/tui_gateway/test_mobile_subagents.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace + +from tui_gateway import mobile_subagents as mobile + + +def test_roster_is_session_and_transport_scoped(monkeypatch): + from tools import delegate_tool as delegate + transport, session = object(), {} + own = dict(subagent_id="own", owner_session_id="s", owner_transport=transport, + owner_session_record=session, agent=object(), goal="test", started_at=1) + other = {**own, "subagent_id": "other", "owner_transport": object()} + reused = {**own, "subagent_id": "reused", "owner_session_record": {}} + monkeypatch.setattr(delegate, "_active_subagents", {"own": own, "other": other, "reused": reused}) + rows = mobile.owned_records("s", transport, session) + assert [row["subagent_id"] for row in rows] == ["own"] + assert mobile.owned_records("s", None, session) == [] + assert mobile.owned_records("wrong", transport, session) == [] + public = mobile.roster(rows)["subagents"][0] + assert public["elapsed_seconds"] > 0 + assert "agent" not in public and "owner_session_record" not in public + + +def test_tail_is_bounded_and_unknown_child_is_unavailable(tmp_path, monkeypatch): + from tools import delegation_live_log + monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path) + path = tmp_path / "transcript.jsonl" + path.write_bytes(b"x" * 20000 + b"end") + rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(path)))] + result = mobile.transcript_tail(rows, "own") + assert result["available"] and result["truncated"] + assert result["bytes_read"] == 16384 and result["text"].endswith("end") + assert not mobile.transcript_tail(rows, "other")["available"] + path.unlink() + assert not mobile.transcript_tail(rows, "own")["available"] + + +def test_tail_rejects_outside_log_root(tmp_path, monkeypatch): + from tools import delegation_live_log + monkeypatch.setattr(delegation_live_log, "live_transcript_root", lambda: tmp_path / "logs") + secret = tmp_path / "private.txt" + secret.write_text("not a transcript") + rows = [dict(subagent_id="own", agent=SimpleNamespace(_live_transcript_path=str(secret)))] + assert not mobile.transcript_tail(rows, "own")["available"] + + +def test_roster_survives_resume_but_tail_keeps_exact_authority(monkeypatch): + from tools import delegate_tool as delegate + parent = SimpleNamespace(session_id="conversation") + record = dict(subagent_id="child", owner_session_id="old-runtime", + owner_agent_session_id="conversation", agent=None) + monkeypatch.setattr(delegate, "_active_subagents", {"child": record}) + session = {"agent": parent} + assert mobile.owned_records("new-runtime", object(), session) == [] + assert len(mobile.owned_records("new-runtime", object(), session, include_lineage=True)) == 1 + + +def test_registered_rpcs_reject_wrong_transport(monkeypatch): + from tui_gateway import server + monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: ({"session_key": "s"}, None)) + monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (None, None)) + for name in ("subagent.list", "subagent.tail", "subagent.interrupt"): + response = server._methods[name](1, {"session_id": "s", "subagent_id": "child"}) + assert response["error"]["code"] == 4001 + + +def test_registered_list_returns_owned_children(monkeypatch): + from tui_gateway import server + from tools import delegate_tool as delegate + transport, session = object(), {"session_key": "s"} + record = dict(subagent_id="child", owner_session_id="s", owner_transport=transport, + owner_session_record=session, goal="test") + monkeypatch.setattr(delegate, "_active_subagents", {"child": record}) + monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None)) + monkeypatch.setattr(server, "_current_session_steer_authority", lambda sid: (transport, session)) + response = server._methods["subagent.list"](1, {"session_id": "s"}) + assert response["result"]["subagents"][0]["subagent_id"] == "child" diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 2b1d0d1..b0da028 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -3572,14 +3572,53 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"paused": set_spawn_paused(paused)}) +@method("subagent.list") +def _(rid, params: dict) -> dict: + from tui_gateway.mobile_subagents import owned_records, roster + _, err = _sess_nowait(params, rid) + if err: + return err + session_id = str(params.get("session_id") or "").strip() + transport, session = _current_session_steer_authority(session_id) + if transport is None or session is None: + return _err(rid, 4001, "session not found or not owned by this transport") + return _ok(rid, roster(owned_records(session_id, transport, session, include_lineage=True))) + + +@method("subagent.tail") +def _(rid, params: dict) -> dict: + from tui_gateway.mobile_subagents import owned_records, transcript_tail + _, err = _sess_nowait(params, rid) + if err: + return err + subagent_id = str(params.get("subagent_id") or "").strip() + if not subagent_id: + return _err(rid, 4000, "subagent_id required") + session_id = str(params.get("session_id") or "").strip() + transport, session = _current_session_steer_authority(session_id) + if transport is None or session is None: + return _err(rid, 4001, "session not found or not owned by this transport") + with _session_home_scope(session): + result = transcript_tail(owned_records(session_id, transport, session), subagent_id) + return _ok(rid, result) + + @method("subagent.interrupt") def _(rid, params: dict) -> dict: - from tools.delegate_tool import interrupt_subagent + from tui_gateway.mobile_subagents import owned_records + from agent.interrupt_compat import request_hard_interrupt subagent_id = str(params.get("subagent_id") or "").strip() if not subagent_id: return _err(rid, 4000, "subagent_id required") - ok = interrupt_subagent(subagent_id) + session_id = str(params.get("session_id") or "").strip() + transport, session = _current_session_steer_authority(session_id) + if transport is None or session is None: + return _err(rid, 4001, "session not found or not owned by this transport") + record = next((r for r in owned_records(session_id, transport, session) + if r.get("subagent_id") == subagent_id), None) + child = record.get("agent") if record else None + ok = bool(request_hard_interrupt(child, "Interrupted via mobile")) if child is not None else False return _ok(rid, {"found": ok, "subagent_id": subagent_id}) diff --git a/tui_gateway/mobile_subagents.py b/tui_gateway/mobile_subagents.py new file mode 100644 index 0000000..bc92464 --- /dev/null +++ b/tui_gateway/mobile_subagents.py @@ -0,0 +1,55 @@ +"""Read-only mobile subagent views, scoped to the invoking live session.""" +import time +from pathlib import Path + + +def owned_records(session_id, transport, session, *, include_lineage=False): + from tools.delegate_tool import _active_subagents, _active_subagents_lock, _owns_subagent_record + if transport is None or session is None: + return [] + with _active_subagents_lock: + records = [dict(row) for row in _active_subagents.values()] + return [row for row in records + if (row.get("owner_session_id") == session_id + and row.get("owner_transport") is transport + and row.get("owner_session_record") is session) + or (include_lineage and _owns_subagent_record(row, session.get("agent")))] + + +def roster(records): + fields = ("subagent_id", "parent_id", "depth", "goal", "delegation_id", "model", + "started_at", "status", "tool_count", "last_tool", "accepting_steer") + rows = [] + for record in records: + row = {key: record[key] for key in fields if key in record} + started = record.get("started_at") + if isinstance(started, (int, float)): + row["elapsed_seconds"] = max(0, time.time() - started) + rows.append(row) + return {"subagents": rows, "delegations": []} + + +def transcript_tail(records, subagent_id): + result = {"subagent_id": subagent_id, "available": False, "text": "", "bytes_read": 0, "truncated": False} + record = next((r for r in records if r.get("subagent_id") == subagent_id), None) + if record is None: + return result + path = getattr(record.get("agent"), "_live_transcript_path", None) + if not isinstance(path, (str, Path)): + return result + try: + # Only the registry's own file is read; no client-supplied path is accepted. + from tools.delegation_live_log import live_transcript_root + path = Path(path) + root = live_transcript_root().resolve() + if path.is_symlink() or not path.resolve().is_relative_to(root): + return result + with path.open("rb") as stream: + size = stream.seek(0, 2) + stream.seek(max(0, size - 16384)) + raw = stream.read(16384) + result.update(available=True, text=raw.decode("utf-8", errors="replace"), + bytes_read=len(raw), truncated=size > len(raw)) + except OSError: + pass # A just-completed child's temporary transcript may already be gone. + return result