diff --git a/contributors/emails/byjaps@users.noreply.github.com b/contributors/emails/byjaps@users.noreply.github.com new file mode 100644 index 0000000000000..b2b0e654914f7 --- /dev/null +++ b/contributors/emails/byjaps@users.noreply.github.com @@ -0,0 +1,2 @@ +byjaps +# PR #116425 fix(cli) plugin toolsets must not be flagged as unknown at startup diff --git a/hermes_cli/cli_init_mixin.py b/hermes_cli/cli_init_mixin.py index 772d9bbbacb1a..adb51f2130b12 100644 --- a/hermes_cli/cli_init_mixin.py +++ b/hermes_cli/cli_init_mixin.py @@ -214,7 +214,16 @@ def _init_toolsets(self, toolsets): if toolsets and "all" not in toolsets and "*" not in toolsets: # MCP server names only resolve after discover_mcp_tools runs; skip them here. mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys()) - invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names] + # Plugin toolsets register during plugin discovery, which startup runs on a background thread + # that has not necessarily landed yet; names it declared (or the previous launch persisted, which + # get_plugin_toolset_keys_nowait serves) are not typos (#71650). + try: + from hermes_cli.plugins import get_plugin_toolset_keys_nowait + plugin_ts_names = get_plugin_toolset_keys_nowait() + except Exception: + plugin_ts_names = set() + invalid = [t for t in toolsets + if not validate_toolset(t) and t not in mcp_names and t not in plugin_ts_names] if invalid: self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]") diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 15fa39d715479..23dff081afdf3 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -43,7 +43,7 @@ ) from hermes_cli.plugins_discovery import ( # noqa: F401 — re-exported ENTRY_POINTS_GROUP, _get_disabled_plugins, _get_enabled_plugins, collect_directory_manifests, - discover_entrypoint_manifests, gate_manifest, scan_directory, + discover_entrypoint_manifests, gate_manifest, resolve_manifest_winners, scan_directory, ) from hermes_cli.plugins_loader import ( PluginLoaderMixin, _BARE_MODULE_SCOPE, _MODULE_NAMESPACE_LOCK, _NS_PARENT, _evict_modules, @@ -1007,6 +1007,10 @@ def register_skill( f"plugin name '{self.manifest.name}' automatically).") if not name or not _NAMESPACE_RE.match(name): raise ValueError(f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+.") + # Plugin register() helpers commonly pass the SKILL.md location as str + # (PluginManifest.path is stored as str); the registry and find_plugin_skill() + # promise a Path downstream. + path = Path(path) if not path.exists(): raise FileNotFoundError(f"SKILL.md not found at {path}") namespace = self.manifest.skill_namespace or self.manifest.name @@ -1332,9 +1336,10 @@ def _discover_and_load_inner(self) -> None: logger.warning("Removed Hermes plugin %s is still listed in plugins.enabled; " "remove it and configure native Relay plugins with %s", ", ".join(stale_relay_keys), RELAY_PLUGINS_CONFIG_ENV) - # Later sources win on key collision (project > user > bundled); gate the winners, then + # Later sources win on key collision (project > user > bundled) except a flat impostor claiming a + # bundled key from another directory (resolve_manifest_winners); gate the winners, then # load survivors in requires_plugins order (see resolve_plugin_load_order). - winners = {manifest_key(m): m for m in manifests} + winners = resolve_manifest_winners(manifests) to_load = {k: m for k, m in winners.items() if self._gate_manifest(m, disabled, enabled)} for lookup_key in resolve_plugin_load_order(to_load): manifest = to_load[lookup_key] diff --git a/hermes_cli/plugins_discovery.py b/hermes_cli/plugins_discovery.py index dd38a5edb8f52..21d5850696fd9 100644 --- a/hermes_cli/plugins_discovery.py +++ b/hermes_cli/plugins_discovery.py @@ -10,7 +10,7 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Any, List, Optional, Set +from typing import Any, Dict, List, Optional, Set from hermes_constants import get_hermes_home from hermes_cli.config import cfg_get @@ -27,6 +27,13 @@ ENTRY_POINTS_GROUP = "hermes_agent.plugins" ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities" +# Per-harness manifest directories plugin repos ship for OTHER agent harnesses (e.g. obra/superpowers keeps one +# plugin.json per harness). Their plugin.json is not an Agent Plugins v1 manifest and can never validate, so +# parsing it on every discovery pass only spams warnings (#101962). +_FOREIGN_HARNESS_MANIFEST_DIRS = frozenset({ + ".claude-plugin", ".codex-plugin", ".cursor-plugin", ".devin-plugin", ".kimi-plugin", +}) + def _select_entry_point_group(entry_points: Any, group: str) -> list: """Return one metadata entry-point group across supported Python APIs.""" @@ -109,7 +116,23 @@ def scan_directory( manifests: List[PluginManifest] = [] if not path.is_dir(): return manifests - for child in sorted(path.iterdir()): + try: + children = sorted(path.iterdir()) + except OSError as exc: + logger.warning("Failed to scan plugin directory %s: %s", path, exc) + return manifests + for child in children: + # Cache/dunder dirs (__pycache__, __MACOSX__, …) are never + # plugins. Walking them can raise PermissionError and take + # down every subsequent tool call (#86996). + if child.name.startswith("__") and child.name.endswith("__"): + logger.debug("Skipping dunder plugin path %s", child) + continue + if child.name in _FOREIGN_HARNESS_MANIFEST_DIRS: + logger.debug("Skipping %s (foreign-harness manifest convention)", child) + continue + # pathlib.Path.is_dir() swallows OSError, but injected Path-likes + # and test doubles can still raise. Fail closed per child. try: if not child.is_dir() or (depth == 0 and skip_names and child.name in skip_names): continue @@ -167,6 +190,32 @@ def _scan(label: str, directory: Path, source: str, skip_names: Optional[Set[str return manifests +def resolve_manifest_winners(manifests: List[PluginManifest]) -> Dict[str, PluginManifest]: + """Later sources win on key collision (project > user > bundled): a same-named copy under + ``~/.hermes/plugins/`` is the documented way to override a bundled plugin, and is logged. A flat + user/project manifest that claims a bundled key from a *differently named* directory is an impostor, not + an override (``impostor_dir/plugin.yaml`` with ``name: kanban``): it is skipped with a warning so + ``hermes plugins enable kanban`` never activates unrelated code under the bundled name.""" + winners: Dict[str, PluginManifest] = {} + for manifest in manifests: + key = manifest_key(manifest) + shadowed = winners.get(key) + if shadowed is not None and shadowed.source == "bundled" and manifest.source in {"user", "project"}: + own_dir = Path(manifest.path).name if manifest.path else "" + bundled_dir = Path(shadowed.path).name if shadowed.path else "" + if own_dir and bundled_dir and own_dir != bundled_dir: + logger.warning( + "Ignoring %s plugin at %s: its manifest name '%s' is a bundled plugin's key but the " + "directory is named '%s'; rename the directory to '%s' to override the bundled plugin", + manifest.source, manifest.path, key, own_dir, bundled_dir, + ) + continue + logger.info("Plugin '%s' at %s (%s) shadows the bundled copy at %s", key, manifest.path, + manifest.source, shadowed.path) + winners[key] = manifest + return winners + + @dataclass(frozen=True) class ManifestGate: """Routing verdict for one winning manifest (see :func:`gate_manifest`).""" diff --git a/hermes_cli/plugins_loader.py b/hermes_cli/plugins_loader.py index 3af0120ebf20e..e072ae9aaf21e 100644 --- a/hermes_cli/plugins_loader.py +++ b/hermes_cli/plugins_loader.py @@ -19,7 +19,7 @@ from contextlib import contextmanager from functools import wraps from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Union from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override from registration_lifecycle import replacement_coordinator @@ -64,6 +64,13 @@ def _plugin_home_scope(home: Path): reset_hermes_home_override(token) +def _load_error_text(exc: BaseException) -> str: + """Human-readable load failure; ``sys.exit(0)`` has an empty ``str()`` so name the class and code.""" + if isinstance(exc, SystemExit): + return f"SystemExit({exc.code!r}) raised during import/register()" + return str(exc) + + def _dist_installed(req: str) -> Optional[bool]: """Best-effort presence probe on a requirement's distribution name; ``None`` when unprobeable.""" dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip() @@ -199,7 +206,7 @@ def _credit() -> List[str]: "Deferred platform '%s': pre-registered %d client tool(s) %s", lookup_key, len(registered), registered, ) - except Exception as exc: + except (Exception, SystemExit) as exc: # Tools registered before the raise are live: credit them or `hermes plugins list` under-reports # (and _load_plugin's later diff would miss them too). Never break discovery (the platform stays # deferred), but a broken tools.py IS the symptom, so warn — and say where it failed first. @@ -302,8 +309,15 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None: module = self._load_directory_module(manifest, module_name=module_name) elif module is None: module = self._load_entrypoint_module(manifest) + register_fn = None + if module is not None and not isinstance(module, types.ModuleType) and callable(module): + # An entry point declared as ``module:function`` resolves to the function object itself via + # ``ep.load()``, not its module (#72052). + register_fn = module + module = sys.modules.get(getattr(register_fn, "__module__", "")) loaded.module = module - register_fn = getattr(module, "register", None) + if register_fn is None: + register_fn = getattr(module, "register", None) if register_fn is None: loaded.error = "no register() function" logger.warning("Plugin '%s' has no register() function", manifest.name) @@ -314,15 +328,17 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None: from hermes_cli.plugins_ledger import _hook_source_of self._drop_fallback_hooks(_hook_source_of(manifest.name, module)) - except Exception as exc: + except (Exception, SystemExit) as exc: + # SystemExit too: a plugin module with an unguarded ``main()``/``sys.exit()`` must not take the + # whole process (and every other plugin's registry) down with it; KeyboardInterrupt still propagates. owned = [r for r in self._registration_order if r.plugin_key == plugin_key] self._dispose_registrations(owned) self._forget_registrations(owned) - loaded.error = str(exc) + loaded.error = _load_error_text(exc) # register() may have subscribed before raising; a failed plugin must leave no callable reachable # from later event dispatch. self._remove_plugin_subscriptions(plugin_key) - logger.warning("Failed to load plugin '%s': %s", manifest.name, exc, exc_info=_PLUGINS_DEBUG) + logger.warning("Failed to load plugin '%s': %s", manifest.name, _load_error_text(exc), exc_info=_PLUGINS_DEBUG) # The failure path swept this plugin's whole ledger (not just the registration_start slice), so # discovery-time pre-registrations are gone too. # There is no live tool left to credit — attribution and the registry agree at zero. Only the @@ -400,9 +416,9 @@ def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) continue self._portable_mcp_servers[internal_name] = dict(config) loaded.enabled = True - except Exception as exc: - loaded.error = str(exc) - logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc) + except (Exception, SystemExit) as exc: + loaded.error = _load_error_text(exc) + logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, loaded.error) self._plugins[lookup_key] = loaded def _directory_module_name(self, manifest: PluginManifest) -> str: @@ -461,8 +477,9 @@ def _load_directory_module( raise return module - def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType: - """Load a pip-installed plugin via its entry-point reference.""" + def _load_entrypoint_module(self, manifest: PluginManifest) -> Union[types.ModuleType, Callable[..., Any]]: + """Load a pip-installed plugin via its entry-point reference: the module for a bare ``module`` target, + the referenced attribute (normally ``register``) for the ``module:function`` form.""" for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP): if ep.name == manifest.name: return ep.load() diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index 92b36904b3c39..e81fe50822b33 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -375,22 +375,35 @@ class PluginManifest: def running_hermes_version() -> str: - """Installed ``hermes-agent`` distribution version, else ``hermes_cli.__version__`` (source checkout).""" + """Version of the Hermes code that is running: ``hermes_cli.__version__``. Distribution metadata is only + a fallback — on an editable/source install it is frozen at ``pip install -e`` time and drifts from the + checkout after every ``git pull`` (dist said 0.21.0 while the code was 0.21.4), so gating on it skipped + plugins that required exactly the release the user was running.""" try: - return importlib.metadata.version("hermes-agent") - except Exception: from hermes_cli import __version__ - return __version__ + if __version__: + return str(__version__) + except Exception: + pass + return importlib.metadata.version("hermes-agent") + + +_VERSION_SEGMENT_RE = re.compile(r"^\d+") def _version_tuple(v: str) -> Optional[tuple]: - """``v1.2.3-rc1`` → ``(1, 2, 3)``; ``None`` when a segment is non-numeric.""" + """``v1.2.3-rc1`` / ``1.2.3rc1`` / ``1.2.3.post1`` → ``(1, 2, 3)``; ``None`` when a segment has no + leading digits. PEP 440 pre/post/dev suffixes glued to a segment (``0rc1``) are dropped so an rc + *target* still gates and an rc *running* version does not disable every gate.""" parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".") parts += ["0"] * (3 - len(parts)) - try: - return tuple(int(x) for x in parts[:3]) - except ValueError: - return None + out = [] + for x in parts[:3]: + m = _VERSION_SEGMENT_RE.match(x.strip()) + if m is None: + return None + out.append(int(m.group(0))) + return tuple(out) def version_satisfies(spec: str, current: str) -> bool: @@ -464,6 +477,10 @@ def parse_manifest_file( logger.warning("PyYAML not installed – cannot load %s", manifest_file) return None data = fast_safe_load(manifest_file.read_text(encoding="utf-8")) or {} + if not isinstance(data, Mapping): + logger.warning("Failed to parse %s: top level must be a mapping, got %s (#14066)", + manifest_file, type(data).__name__) + return None name = data.get("name", plugin_dir.name) key = f"{prefix}/{plugin_dir.name}" if prefix else name kind = _manifest_kind(data, key, plugin_dir) @@ -474,7 +491,9 @@ def parse_manifest_file( description=data.get("description", ""), author=_display_author(data.get("author", "")), requires_env=data.get("requires_env", []), provides_tools=data.get("provides_tools", []), - provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir), + # ``hooks:`` is the spelling the bundled manifests carried for months; external copies of it + # must keep declaring the same thing (#108371). + provides_hooks=data.get("provides_hooks", data.get("hooks", [])), source=source, path=str(plugin_dir), kind=kind, key=key, requires_hermes=str(data.get("requires_hermes") or "").strip(), capabilities=_parse_declared_capabilities(data.get("capabilities"), name), **_parse_manifest_v2_fields(data, key), emits=data.get("emits") or [], diff --git a/plugins/disk-cleanup/plugin.yaml b/plugins/disk-cleanup/plugin.yaml index fe005c88491b0..75b163db7a2ea 100644 --- a/plugins/disk-cleanup/plugin.yaml +++ b/plugins/disk-cleanup/plugin.yaml @@ -2,6 +2,6 @@ name: disk-cleanup version: 2.0.0 description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required." author: "@LVT382009 (original), NousResearch (plugin port)" -hooks: +provides_hooks: - post_tool_call - on_session_end diff --git a/plugins/google_meet/plugin.yaml b/plugins/google_meet/plugin.yaml index 519d6e09c85fc..0d6b2e0f35840 100644 --- a/plugins/google_meet/plugin.yaml +++ b/plugins/google_meet/plugin.yaml @@ -12,5 +12,5 @@ provides_tools: - meet_status - meet_transcript - meet_say -hooks: +provides_hooks: - on_session_end diff --git a/plugins/memory/byterover/plugin.yaml b/plugins/memory/byterover/plugin.yaml index a6645c3c52989..bdfc1045f11d7 100644 --- a/plugins/memory/byterover/plugin.yaml +++ b/plugins/memory/byterover/plugin.yaml @@ -5,5 +5,3 @@ external_dependencies: - name: brv install: "curl -fsSL https://byterover.dev/install.sh | sh" check: "brv --version" -hooks: - - on_pre_compress diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml index 9dfa763af7f49..3495aa63cc9fd 100644 --- a/plugins/memory/hindsight/plugin.yaml +++ b/plugins/memory/hindsight/plugin.yaml @@ -4,5 +4,3 @@ description: "Hindsight — long-term memory with knowledge graph, entity resolu pip_dependencies: - "hindsight-client>=0.6.1" requires_env: [] -hooks: - - on_session_end diff --git a/plugins/memory/holographic/plugin.yaml b/plugins/memory/holographic/plugin.yaml index ae7d78f8daed3..497cc2e903908 100644 --- a/plugins/memory/holographic/plugin.yaml +++ b/plugins/memory/holographic/plugin.yaml @@ -1,5 +1,3 @@ name: holographic version: 0.1.0 description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval." -hooks: - - on_session_end diff --git a/plugins/memory/honcho/plugin.yaml b/plugins/memory/honcho/plugin.yaml index 38a0612c9774c..8717aa2a39196 100644 --- a/plugins/memory/honcho/plugin.yaml +++ b/plugins/memory/honcho/plugin.yaml @@ -3,5 +3,3 @@ version: 1.0.0 description: "Honcho AI-native memory — cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions." pip_dependencies: - honcho-ai -hooks: - - on_session_end diff --git a/plugins/memory/openviking/plugin.yaml b/plugins/memory/openviking/plugin.yaml index 18b8ea7874154..faa1b69074482 100644 --- a/plugins/memory/openviking/plugin.yaml +++ b/plugins/memory/openviking/plugin.yaml @@ -4,5 +4,3 @@ description: "OpenViking context database — session-managed memory with automa pip_dependencies: - httpx requires_env: [] -hooks: - - on_session_end diff --git a/plugins/observability/langfuse/plugin.yaml b/plugins/observability/langfuse/plugin.yaml index e1244e610aaea..9627e16e36154 100644 --- a/plugins/observability/langfuse/plugin.yaml +++ b/plugins/observability/langfuse/plugin.yaml @@ -5,7 +5,7 @@ author: NousResearch requires_env: - HERMES_LANGFUSE_PUBLIC_KEY - HERMES_LANGFUSE_SECRET_KEY -hooks: +provides_hooks: - pre_api_request - post_api_request - api_request_error diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 84111d6704549..9c93a12644af9 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -120,6 +120,8 @@ def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: Tuple[str sub_mod = _new_module(full_sub_name, sub_file) if _exec(sub_mod, logger): loaded_submodules.append((sub_file.stem, sub_mod)) + else: + sys.modules.pop(full_sub_name, None) if not _exec(mod, logger): sys.modules.pop(module_name, None) return None diff --git a/plugins/security-guidance/plugin.yaml b/plugins/security-guidance/plugin.yaml index 97567299954b5..0224a28029806 100644 --- a/plugins/security-guidance/plugin.yaml +++ b/plugins/security-guidance/plugin.yaml @@ -2,6 +2,6 @@ name: security-guidance version: "0.1.0" description: "Append security warnings to file-write tool results when the new content contains known-dangerous patterns (pickle.load, yaml.load, eval(, os.system, dangerouslySetInnerHTML, verify=False, ECB, XXE, GitHub Actions injection, ...). 25 regex/substring rules forked from Anthropic's claude-plugins-official under Apache-2.0. Non-blocking — the file is written and the warning rides back to the model in the next turn so it can self-correct." author: "Anthropic (patterns, Apache-2.0) / NousResearch (Hermes plugin port)" -hooks: +provides_hooks: - transform_tool_result - pre_tool_call diff --git a/pyproject.toml b/pyproject.toml index e366b8459da89..e38e5a8349e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,10 +258,12 @@ wake = [ ] honcho = ["honcho-ai==2.2.0"] # Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py -# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the -# LAZY_DEPS pins (enforced by tests/test_project_metadata.py). Deliberately -# excluded from [all] like honcho/hindsight so a quarantined upstream release -# can't break fresh installs. +# (memory.supermemory / memory.mem0) at first use. Where BOTH pin exactly the +# versions MUST match (enforced by tests/test_project_metadata.py); LAZY_DEPS +# entries that mirror a plugin.yaml range (mem0ai, hindsight-client) keep the +# extra as the floor install and never downgrade a newer compatible release. +# Deliberately excluded from [all] like honcho/hindsight so a quarantined +# upstream release can't break fresh installs. supermemory = ["supermemory==3.50.0"] mem0 = ["mem0ai==2.0.10"] # Image resize recovery for the vision tools. Pillow is now a CORE dependency diff --git a/tests/hermes_cli/test_cli_init.py b/tests/hermes_cli/test_cli_init.py index 7bcb49b1639ae..83ba5d041bc3b 100644 --- a/tests/hermes_cli/test_cli_init.py +++ b/tests/hermes_cli/test_cli_init.py @@ -763,4 +763,52 @@ def test_flat_string_default_untouched(self): assert result["model"]["provider"] == "auto" +class TestPluginToolsetStartupValidation: + """A toolset that is merely *not registered yet* must not be reported as unknown. + + Plugins register their toolsets during background discovery, while the CLI validates + the configured list during construction -- i.e. before that thread has landed. Judging + by the live registry alone therefore flags every configured plugin toolset as a typo on + every launch, including one-shot/quiet runs whose stdout is machine-parsed. + """ + + @staticmethod + def _init_toolsets(monkeypatch, toolsets, *, registry, plugin_keys): + import cli as _cli_mod + + stub = object.__new__(_cli_mod.HermesCLI) + printed: list[str] = [] + stub._console_print = printed.append + monkeypatch.setattr(_cli_mod, "validate_toolset", lambda name: name in registry) + monkeypatch.setattr(_cli_mod, "CLI_CONFIG", {"agent": {}}) + monkeypatch.setattr( + "hermes_cli.plugins.get_plugin_toolset_keys_nowait", + lambda: set(plugin_keys), + ) + stub._init_toolsets(list(toolsets)) + return stub, printed + + def test_plugin_toolset_not_yet_registered_is_not_flagged(self, monkeypatch): + stub, printed = self._init_toolsets( + monkeypatch, + ["terminal", "voice_stack"], + registry={"terminal"}, + plugin_keys={"voice_stack"}, + ) + assert printed == [] + # The configured list is kept verbatim; only the false warning is silenced. + assert stub.enabled_toolsets == ["terminal", "voice_stack"] + + def test_real_typo_still_warns(self, monkeypatch): + _, printed = self._init_toolsets( + monkeypatch, + ["terminal", "voice_stak"], + registry={"terminal"}, + plugin_keys={"voice_stack"}, + ) + assert len(printed) == 1 + assert "voice_stak" in printed[0] + assert "voice_stack" not in printed[0] + + diff --git a/tests/hermes_cli/test_plugin_manifest_v2.py b/tests/hermes_cli/test_plugin_manifest_v2.py index d31385dbaf690..73a64f7a9eff7 100644 --- a/tests/hermes_cli/test_plugin_manifest_v2.py +++ b/tests/hermes_cli/test_plugin_manifest_v2.py @@ -412,6 +412,27 @@ def test_has_plugin_probe(self, hermes_home): class TestRequiresHermes: + def test_gate_reads_the_running_code_version_not_dist_metadata(self, monkeypatch): + """An editable install's dist metadata is frozen at install time (0.21.0 here) while the checkout runs + 0.21.4; the gate must compare against the code that is running.""" + import importlib.metadata + from hermes_cli import plugins_manifest + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.21.0") + monkeypatch.setattr("hermes_cli.__version__", "0.21.4") + assert plugins_manifest.running_hermes_version() == "0.21.4" + assert plugins_manifest.version_satisfies(">=0.21.4", plugins_manifest.running_hermes_version()) + + @pytest.mark.parametrize("spec, current, expected", [ + (">=99.0.0rc1", "0.21.4", False), # rc target used to parse as None -> clause silently dropped + (">=0.23.0", "0.22.0rc1", False), # rc running version used to disable every gate + (">=0.21.0", "0.22.0rc1", True), + (">=1.2.3.post1", "1.2.3", True), + ("banana", "0.21.4", True), # documented: unparseable target stays permissive + ]) + def test_prerelease_spellings_gate(self, spec, current, expected): + from hermes_cli.plugins_manifest import version_satisfies + assert version_satisfies(spec, current) is expected + def test_unsatisfied_requires_hermes_skips_without_importing(self, hermes_home, monkeypatch): """A too-new ``requires_hermes`` records an error and never runs register(); a satisfied one loads.""" import sys @@ -434,6 +455,84 @@ def test_unsatisfied_requires_hermes_skips_without_importing(self, hermes_home, delattr(sys, attr) +class TestLoadIsolation: + def test_sys_exit_in_plugin_is_isolated_and_named(self, hermes_home, caplog): + """A plugin calling ``sys.exit()`` at import used to propagate SystemExit out of discovery: the whole + registry emptied, ``_discovered`` reset and ``hermes chat`` exited 3 with no output. It must be + recorded as that plugin's error while later plugins still load.""" + _write_plugin(hermes_home / "plugins", "b_exit") + (hermes_home / "plugins" / "b_exit" / "__init__.py").write_text("import sys\nsys.exit(0)\n") + _write_plugin(hermes_home / "plugins", "c_after") + _enable(hermes_home, ["b_exit", "c_after"]) + mgr = PluginManager() + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + mgr.discover_and_load() # must not raise + assert mgr._discovered is True + assert mgr._plugins["c_after"].enabled + assert not mgr._plugins["b_exit"].enabled + assert "SystemExit(0)" in (mgr._plugins["b_exit"].error or "") + + def test_keyboard_interrupt_still_propagates(self, hermes_home): + _write_plugin(hermes_home / "plugins", "ctrlc") + (hermes_home / "plugins" / "ctrlc" / "__init__.py").write_text("raise KeyboardInterrupt\n") + _enable(hermes_home, ["ctrlc"]) + with pytest.raises(KeyboardInterrupt): + PluginManager().discover_and_load() + + +class TestBundledKeyShadowing: + def test_impostor_dir_cannot_claim_a_bundled_key(self, tmp_path, monkeypatch, caplog): + """``~/.hermes/plugins/impostor_dir/plugin.yaml`` with ``name: `` used to displace the + bundled plugin silently, so ``hermes plugins enable `` enabled unrelated code. The bundled + manifest wins and the impostor is warned about; a same-named user copy still overrides (documented).""" + home = tmp_path / "home" + (home / "plugins").mkdir(parents=True) + bundled = tmp_path / "bundled" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "0") + monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled)) + _write_plugin(bundled, "genuine", register_body="import sys; sys._shadow_probe = 'bundled'") + _write_plugin(bundled, "overridable", register_body="import sys; sys._override_probe = 'bundled'") + _write_plugin(home / "plugins", "impostor_dir", manifest_extra={"name": "genuine"}, + register_body="import sys; sys._shadow_probe = 'impostor'") + (home / "plugins" / "impostor_dir" / "plugin.yaml").write_text( + yaml.dump({"name": "genuine", "version": "0.1.0", "description": "impostor"})) + _write_plugin(home / "plugins", "overridable", register_body="import sys; sys._override_probe = 'user'") + _enable(home, ["genuine", "overridable"]) + import sys + try: + with caplog.at_level(logging.INFO, logger="hermes_cli.plugins"): + mgr = PluginManager() + mgr.discover_and_load() + assert mgr._plugins["genuine"].manifest.source == "bundled" + assert sys._shadow_probe == "bundled" + assert "impostor_dir" in caplog.text and "rename the directory" in caplog.text + assert mgr._plugins["overridable"].manifest.source == "user" + assert sys._override_probe == "user" + assert "shadows the bundled copy" in caplog.text + finally: + for attr in ("_shadow_probe", "_override_probe"): + if hasattr(sys, attr): + delattr(sys, attr) + + +class TestManifestParsingRobustness: + def test_list_manifest_is_rejected_with_a_clear_reason_and_hooks_alias(self, hermes_home, caplog): + """A list-typed plugin.yaml (#14066) names the actual problem instead of an AttributeError; the + long-standing ``hooks:`` spelling still populates ``provides_hooks`` (#108371).""" + from hermes_cli.plugins_discovery import scan_directory + bad = hermes_home / "plugins" / "listy" + bad.mkdir() + (bad / "plugin.yaml").write_text("- name: listy\n") + good = _write_plugin(hermes_home / "plugins", "hooky", manifest_extra={"hooks": ["pre_tool_call"]}) + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + manifests = {m.name: m for m in scan_directory(hermes_home / "plugins", "user")} + assert "listy" not in manifests + assert "top level must be a mapping" in caplog.text + assert manifests["hooky"].provides_hooks == ["pre_tool_call"] + assert manifests["hooky"].path == str(good) + + class TestDirectoryPluginKeepsIdentityOverEntryPoint: """A pyproject-wrapper plugin depends on a pip package that ships a ``hermes_agent.plugins`` entry point under the SAME name. The installed directory must stay the plugin's identity (it diff --git a/tests/hermes_cli/test_plugin_scan_dunder_dirs.py b/tests/hermes_cli/test_plugin_scan_dunder_dirs.py new file mode 100644 index 0000000000000..cc90dd9c25444 --- /dev/null +++ b/tests/hermes_cli/test_plugin_scan_dunder_dirs.py @@ -0,0 +1,53 @@ +"""Plugin directory scans must skip dunder dirs and survive OSError (#86996).""" + +from __future__ import annotations + +from pathlib import Path + +from hermes_cli.plugins import PluginManager + + +def test_scan_skips_dunder_dirs_and_still_loads_real_plugin(tmp_path: Path): + root = tmp_path / "plugins" + demo = root / "demo" + demo.mkdir(parents=True) + (demo / "plugin.yaml").write_text("name: demo\nversion: 0.1.0\ndescription: x\n") + (demo / "__pycache__").mkdir() + (root / "__pycache__").mkdir() + (root / "__MACOSX__").mkdir() + + found = PluginManager()._scan_directory(root, "user") + + assert [manifest.name for manifest in found] == ["demo"] + + +def test_scan_survives_iterdir_oserror(tmp_path: Path, monkeypatch): + root = tmp_path / "plugins" + root.mkdir() + + def _boom(_self): + raise PermissionError("unreadable plugin root") + + monkeypatch.setattr(Path, "iterdir", _boom, raising=False) + found = PluginManager()._scan_directory(root, "user") + assert found == [] + + +def test_scan_skips_child_whose_is_dir_raises(tmp_path: Path, monkeypatch): + root = tmp_path / "plugins" + demo = root / "demo" + demo.mkdir(parents=True) + (demo / "plugin.yaml").write_text("name: demo\nversion: 0.1.0\ndescription: x\n") + spooky = root / "spooky" + spooky.mkdir() + + original_is_dir = Path.is_dir + + def _is_dir(self): + if self.name == "spooky": + raise PermissionError("stat failed") + return original_is_dir(self) + + monkeypatch.setattr(Path, "is_dir", _is_dir) + found = PluginManager()._scan_directory(root, "user") + assert [manifest.name for manifest in found] == ["demo"] diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index 4a0614f959276..8ac876f43d251 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any, Dict @@ -113,6 +114,74 @@ def test_depth_cap_two(self, tmp_path, monkeypatch): assert non_bundled == [] +# ── Foreign-harness manifest dirs (#101962) ──────────────────────────────── + + +class TestForeignHarnessManifestDirs: + def test_foreign_harness_dirs_skipped_without_warnings( + self, tmp_path, monkeypatch, caplog + ): + """Multi-harness plugin repos (e.g. obra/superpowers) ship one + ``plugin.json`` per OTHER agent harness inside ``.claude-plugin/``, + ``.codex-plugin/`` etc. Those manifests can never satisfy the Agent + Plugins v1 schema, so scanning them warned on every discovery pass. + They must be skipped silently; the plugin's real Hermes manifest + (``.hermes-plugin/plugin.yaml``) is still discovered.""" + import os + hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture + sp = hermes_home / "plugins" / "superpowers" + (sp / ".hermes-plugin").mkdir(parents=True) + (sp / ".hermes-plugin" / "plugin.yaml").write_text( + yaml.dump( + { + "name": "superpowers", + "version": "6.3.0", + "description": "multi-harness plugin", + } + ) + ) + for harness in ( + ".claude-plugin", + ".codex-plugin", + ".cursor-plugin", + ".devin-plugin", + ".kimi-plugin", + ): + harness_dir = sp / harness + harness_dir.mkdir(parents=True) + (harness_dir / "plugin.json").write_text( + json.dumps({"name": "superpowers", "version": "6.3.0"}) + ) + + with caplog.at_level("WARNING", logger="hermes_cli.plugins"): + mgr = PluginManager() + mgr.discover_and_load() + + assert "superpowers/.hermes-plugin" in mgr._plugins + parse_warnings = [ + r for r in caplog.records if "Failed to parse" in r.getMessage() + ] + assert parse_warnings == [] + + def test_broken_portable_plugin_still_warns(self, tmp_path, monkeypatch, caplog): + """A genuinely broken portable plugin.json (not a foreign-harness + convention directory) must still surface its parse warning.""" + import os + hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture + broken = hermes_home / "plugins" / "broken-portable" + broken.mkdir(parents=True) + (broken / "plugin.json").write_text(json.dumps({"name": "broken"})) + + with caplog.at_level("WARNING", logger="hermes_cli.plugins"): + mgr = PluginManager() + mgr.discover_and_load() + + assert any( + "Failed to parse" in r.getMessage() and "broken-portable" in r.getMessage() + for r in caplog.records + ) + + # ── Kind parsing ─────────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 0459d26500cf3..d4e5e0ca65128 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -395,6 +395,54 @@ def _boom(self_inner): + def test_entry_point_function_form_registers(self, tmp_path, monkeypatch): + """Entry points declared as ``module:function`` register via the callable. + + Regression for #72052: real ``EntryPoint.load()`` returns the referenced + attribute for the ``module:function`` form, not the module. The loader + used to look for ``.register`` on that function object, find nothing, + and warn "no register() function" on every discovery pass. + """ + hermes_home = tmp_path / "hermes_test" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Entry-point plugins load only when opted into plugins.enabled. + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["fn_plugin"]}}) + ) + + fake_module = types.ModuleType("fake_fn_plugin") + register_calls = [] + + def register(ctx): + register_calls.append(ctx) + + register.__module__ = "fake_fn_plugin" + fake_module.register = register # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_fn_plugin", fake_module) + + fake_ep = MagicMock() + fake_ep.name = "fn_plugin" + fake_ep.value = "fake_fn_plugin:register" + fake_ep.group = ENTRY_POINTS_GROUP + # Mirror real importlib behavior: load() resolves to the attribute. + fake_ep.load.return_value = register + + def fake_entry_points(): + result = MagicMock() + result.select = MagicMock(return_value=[fake_ep]) + return result + + with patch("importlib.metadata.entry_points", fake_entry_points): + mgr = PluginManager() + mgr.discover_and_load() + + entry = mgr._plugins["fn_plugin"] + assert entry.error is None, entry.error + assert entry.enabled + assert len(register_calls) == 1 + assert entry.module is fake_module + def test_force_rediscover_clears_all_plugin_registries(self, monkeypatch): """force=True must clear every plugin-populated registry. diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index 3887491d0a6a3..4e5d58cf837d3 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -27,8 +27,8 @@ def test_manifest_fields(self): data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text()) assert data["name"] == "langfuse" assert data["version"] - # All eleven hooks the plugin implements. - assert set(data["hooks"]) == { + # All eleven hooks the plugin implements, declared under the field discovery/validate read (#108371). + assert set(data["provides_hooks"]) == { "pre_api_request", "post_api_request", "api_request_error", "pre_llm_call", "post_llm_call", "pre_tool_call", "post_tool_call", diff --git a/tests/plugins/test_plugin_loader.py b/tests/plugins/test_plugin_loader.py new file mode 100644 index 0000000000000..1ed3e8e1a4168 --- /dev/null +++ b/tests/plugins/test_plugin_loader.py @@ -0,0 +1,70 @@ +"""Regression tests for directory-plugin module loading.""" + +from __future__ import annotations + +import logging +import sys + +from plugins.plugin_loader import load_plugin_module + + +def test_failed_sibling_is_removed_before_init_handles_missing_import(tmp_path): + """A failed eager sibling import must remain catchable as ModuleNotFoundError.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "broken.py").write_text( + "from .missing_dependency import value\n", + encoding="utf-8", + ) + (plugin_dir / "__init__.py").write_text( + "try:\n" + " from .broken import value\n" + "except ModuleNotFoundError:\n" + " fallback_used = True\n", + encoding="utf-8", + ) + module_name = "test_plugin_loader_package.failed_sibling" + + try: + module = load_plugin_module( + module_name, + plugin_dir, + parents=(), + logger=logging.getLogger(__name__), + ) + + assert module is not None + assert module.fallback_used is True + assert f"{module_name}.broken" not in sys.modules + finally: + for name in tuple(sys.modules): + if name == module_name or name.startswith(f"{module_name}."): + sys.modules.pop(name, None) + + +def test_successful_sibling_remains_available_on_loaded_module(tmp_path): + """Cleaning failed siblings must not alter the eager success path.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "helper.py").write_text("value = 42\n", encoding="utf-8") + (plugin_dir / "__init__.py").write_text( + "from .helper import value\n", + encoding="utf-8", + ) + module_name = "test_plugin_loader_package.successful_sibling" + + try: + module = load_plugin_module( + module_name, + plugin_dir, + parents=(), + logger=logging.getLogger(__name__), + ) + + assert module is not None + assert module.value == 42 + assert module.helper.value == 42 + finally: + for name in tuple(sys.modules): + if name == module_name or name.startswith(f"{module_name}."): + sys.modules.pop(name, None) diff --git a/tests/plugins/test_security_guidance_plugin.py b/tests/plugins/test_security_guidance_plugin.py index a00bafddcde98..47d7dab640ea1 100644 --- a/tests/plugins/test_security_guidance_plugin.py +++ b/tests/plugins/test_security_guidance_plugin.py @@ -262,13 +262,34 @@ def test_blocks_in_block_mode_on_dangerous_pattern(self, monkeypatch): # --------------------------------------------------------------------------- class TestPluginDiscovery: + def test_manifest_declares_registered_hooks(self): + """Manifest metadata must use the field consumed by plugin discovery.""" + import yaml + + plugin_dir = _repo_root() / "plugins" / "security-guidance" + manifest = yaml.safe_load( + (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") + ) + mod = _load_plugin_init() + registered = [] + + class HookContext: + def register_hook(self, name, _callback): + registered.append(name) + + mod.register(HookContext()) + assert set(manifest["provides_hooks"]) == set(registered) + assert "hooks" not in manifest + def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch): """End-to-end: enable in config.yaml and verify the PluginManager picks it up via the standard discovery path.""" import yaml config = {"plugins": {"enabled": ["security-guidance"]}} - (_isolate_env / "config.yaml").write_text(yaml.safe_dump(config)) + (_isolate_env / "config.yaml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) # Wipe any cached plugin state from earlier tests in this worker. for k in list(sys.modules): diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 5adf2ba8c3997..32a082636cd80 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -223,6 +223,13 @@ def test_extras_block_mismatch_returns_false(self, monkeypatch): self._fake_version(monkeypatch, {"mautrix": "0.20.0"}) assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False + def test_plugin_owned_sdk_newer_compatible_release_is_satisfied(self, monkeypatch): + """A newer release inside the plugin.yaml range must not be re-pinned downward on refresh + (#86992 hindsight-client 0.9.x -> 0.6.1, #98407 mem0ai 2.0.19 -> 2.0.10).""" + self._fake_version(monkeypatch, {"hindsight-client": "0.9.2", "mem0ai": "2.0.19"}) + assert ld.feature_missing("memory.hindsight") == () + assert ld.feature_missing("memory.mem0") == () + def test_trace_upload_hub_at_core_locked_version_is_current(self, monkeypatch): """#60783 regression: refresh must not churn the shared hub install. @@ -426,6 +433,34 @@ def fake_satisfied(spec): class TestInstallSpecs: + def test_uv_tier_runs_from_the_checkout_so_exclude_newer_applies(self, monkeypatch, tmp_path): + """uv reads ``[tool.uv] exclude-newer`` from the cwd project only; a plugin-dep install launched from + $HOME or a gateway service must still run under the checkout's quarantine, so the uv invocation + carries the checkout root as cwd (#L1-3 of the 2026-09 plugin audit).""" + import subprocess + from pathlib import Path + + calls = [] + + def fake_run(cmd, **kw): + calls.append((cmd, kw)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(ld, "_run_installer", fake_run) + monkeypatch.setattr(ld, "_uv_binary", lambda: "/fake/uv") + monkeypatch.setattr(ld, "_lazy_install_target", lambda: None) + monkeypatch.setattr(ld, "_after_successful_install", lambda *a, **kw: None) + monkeypatch.chdir(tmp_path) + project_root = Path(ld.__file__).resolve().parent.parent + assert (project_root / "pyproject.toml").is_file() + + result = ld._venv_pip_install(("requests==2.32.0",)) + + assert result.success + (cmd, kw), = calls + assert cmd[:3] == ["/fake/uv", "pip", "install"] + assert kw.get("cwd") == str(project_root) + def test_empty_specs_is_trivially_ok(self, monkeypatch): monkeypatch.setattr( ld, "_venv_pip_install", diff --git a/tests/tools/test_plugin_skills.py b/tests/tools/test_plugin_skills.py index b2f49061fa3d7..5a58b3ad6c3bb 100644 --- a/tests/tools/test_plugin_skills.py +++ b/tests/tools/test_plugin_skills.py @@ -139,6 +139,26 @@ def test_rejects_missing_file(self, ctx, tmp_path): with pytest.raises(FileNotFoundError): ctx.register_skill("foo", tmp_path / "nonexistent.md") + def test_accepts_str_path(self, ctx, tmp_path): + # Plugin register() helpers commonly pass the SKILL.md location as str + # (#104404); this used to abort the whole plugin load with + # "'str' object has no attribute 'exists'" instead of registering. + from pathlib import Path + + skill_md = tmp_path / "skills" / "my-skill" / "SKILL.md" + skill_md.parent.mkdir(parents=True) + skill_md.write_text("---\nname: my-skill\n---\nContent.\n") + + ctx.register_skill("my-skill", str(skill_md), "A test skill") + + found = ctx._manager.find_plugin_skill("testplugin:my-skill") + assert found == skill_md + assert isinstance(found, Path) + + def test_missing_str_path_raises_filenotfound(self, ctx, tmp_path): + with pytest.raises(FileNotFoundError): + ctx.register_skill("foo", str(tmp_path / "nonexistent.md")) + def test_duplicate_qualified_name_is_rejected(self, ctx, tmp_path): ctx.manifest.portable = True first = tmp_path / "first" / "SKILL.md" diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 675dd5d21f8e8..7e33d431acaa7 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -107,11 +107,15 @@ # ─── Memory providers ────────────────────────────────────────────────── "memory.honcho": ("honcho-ai==2.2.0",), - "memory.hindsight": ("hindsight-client==0.6.1",), + # Plugin-owned SDKs mirror the range their plugin.yaml declares instead of an exact pin: an exact pin + # made _is_satisfied() reject every newer compatible release, so `hermes update` (and the hindsight + # plugin's own >=_MIN_CLIENT_VERSION auto-upgrade) kept downgrading a working 0.9.x client to 0.6.1 + # and broke embedded daemons whose DB a newer client had migrated (#86992, #39424, #98407). + "memory.hindsight": ("hindsight-client>=0.6.1,<1",), # Cloud memory SDKs MUST be allowlisted + ensure()'d at the import site, or they never # install on the sealed Docker image (durable-target only). "memory.supermemory": ("supermemory==3.50.0",), - "memory.mem0": ("mem0ai==2.0.10",), + "memory.mem0": ("mem0ai>=2.0.10,<3",), # ─── Messaging platforms (lazy-installable on demand) ────────────────── "platform.telegram": ("python-telegram-bot[webhooks]==22.8",), @@ -528,6 +532,15 @@ def _run_installer(cmd: list[str], **kw) -> subprocess.CompletedProcess: return subprocess.run(cmd, **_SUBPROCESS_KW, creationflags=windows_hide_flags(), **kw) +def _uv_policy_cwd() -> Optional[str]: + """Directory uv must run from so the checkout's ``[tool.uv]`` policy (``exclude-newer`` quarantine and its + per-package exceptions) applies: uv reads it from the *current directory's* project only, so a lazy or + plugin install launched from ``$HOME``, a gateway service or the Desktop backend was never quarantined. + ``None`` (inherit cwd) when this is not a source checkout.""" + root = Path(__file__).resolve().parent.parent + return str(root) if (root / "pyproject.toml").is_file() else None + + def _uv_binary() -> Optional[str]: """Managed uv first ($HERMES_HOME/bin is never on PATH), then PATH. A lookup, not ensure_uv(): downloading uv mid-turn is more than the caller asked for; pip covers no-uv.""" @@ -599,7 +612,8 @@ def _finish(r: subprocess.CompletedProcess) -> _InstallResult: if pip_index_url: uv_env["UV_INDEX_URL"] = pip_index_url try: - r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], timeout=timeout, env=uv_env) + r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], + timeout=timeout, env=uv_env, cwd=_uv_policy_cwd()) if r.returncode != 0: logger.debug("uv pip install failed: %s", r.stderr) # A uv resolver failure is authoritative: falling through to pip would discard uv