diff --git a/hermes_cli/plugin_compat.py b/hermes_cli/plugin_compat.py index f3cd758322..310b077f1f 100644 --- a/hermes_cli/plugin_compat.py +++ b/hermes_cli/plugin_compat.py @@ -148,7 +148,7 @@ def scan_source(src: str, rel: str, manifest: Dict[str, Dict[str, str]]) -> List return sorted(set(hits), key=lambda h: (h.file, h.line, h.old)) -def scan_plugin(plugin_dir: Path, manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]: +def scan_plugin(plugin_dir: Optional[Path], manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]: manifest = load_manifest() if manifest is None else manifest if not manifest or not plugin_dir or not Path(plugin_dir).is_dir(): return [] @@ -168,6 +168,31 @@ _report_lock = threading.Lock() _report_cache: Dict[Tuple[str, ...], Dict[str, List[Hit]]] = {} +def _scan_root(manifest) -> Optional[Path]: + """Directory to scan for ONE manifest, or None when there is nothing safe to scan. + + Directory plugins carry their own dir. Entry points carry ``module:attr``: resolve the module + through import metadata to its installed package dir. Never fall back to a relative path — that + made ``C:\\...`` and ``pkg:attr`` scan the CWD and attribute stray files to the plugin. + """ + if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None): + return None + raw = str(manifest.path) + if getattr(manifest, "source", "") == "entrypoint": + import importlib.util + try: + spec = importlib.util.find_spec(raw.partition(":")[0]) + except (ImportError, ValueError): + spec = None + origin = getattr(spec, "origin", None) + if not origin or origin in ("built-in", "frozen"): + return None + p = Path(origin) + return p.parent if p.name == "__init__.py" else None + p = Path(raw) + return p if p.is_dir() else None + + def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit]]: """``{plugin_name: hits}`` for every ENABLED external (non-bundled) plugin with at least one hit. @@ -189,9 +214,7 @@ def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit] manifest = load_manifest() out: Dict[str, List[Hit]] = {} for m in external: - d = Path(str(m.path).partition(":")[0]) - d = d if d.is_dir() else d.parent - hits = scan_plugin(d, manifest) + hits = scan_plugin(_scan_root(m), manifest) if hits: out[m.name] = hits with _report_lock: @@ -234,10 +257,7 @@ def _write_report_file(report: Dict[str, List[Hit]]) -> None: def plugin_hits(manifest) -> List[Hit]: """Hits for ONE manifest (used by the loader before importing it).""" - if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None): - return [] - d = Path(str(manifest.path).partition(":")[0]) - return scan_plugin(d if d.is_dir() else d.parent) + return scan_plugin(_scan_root(manifest)) def allow_deprecated_imports(config: Optional[dict] = None) -> bool: @@ -246,7 +266,8 @@ def allow_deprecated_imports(config: Optional[dict] = None) -> bool: if config is None: from hermes_cli.config import load_config_readonly config = load_config_readonly() - return bool(((config or {}).get("plugins") or {}).get(ALLOW_KEY, False)) + # Literal boolean only: YAML `"false"` / `"no"` must not open the post-removal bypass. + return ((config or {}).get("plugins") or {}).get(ALLOW_KEY, False) is True except Exception: return False @@ -268,7 +289,11 @@ def summary_lines(report: Dict[str, List[Hit]], *, today: Optional[_dt.date] = N return [] n = len(report) names = ", ".join(f"{k} ({len(v)})" for k, v in sorted(report.items())) - if removal_in_effect(today): + if removal_in_effect(today) and allow_deprecated_imports(): + head = (f"{n} plugin{'s' if n != 1 else ''} force-loaded via plugins.{ALLOW_KEY}: they import paths " + f"removed on {COMPAT_REMOVAL}: {names}") + tail = "Update the plugin(s); the old paths no longer exist. Details: hermes plugins compat" + elif removal_in_effect(today): head = (f"{n} plugin{'s' if n != 1 else ''} DISABLED: they import paths removed on {COMPAT_REMOVAL}: {names}") tail = f"Update the plugin(s) or set plugins.{ALLOW_KEY}: true to force-load. Details: hermes plugins compat" else: diff --git a/tests/test_plugin_compat_notice.py b/tests/test_plugin_compat_notice.py index fb5ef58138..c8574a86c2 100644 --- a/tests/test_plugin_compat_notice.py +++ b/tests/test_plugin_compat_notice.py @@ -136,3 +136,30 @@ def test_discovery_refreshes_report_file(tmp_path, monkeypatch): (plugin / "__init__.py").write_text("from tools.tool_backend_helpers import prefers_gateway\ndef register(ctx):\n pass\n") mgr._refresh_plugin_compat_report([real]) assert not (tmp_path / "r.json").exists() + + +def test_scan_root_never_falls_back_to_cwd(tmp_path, monkeypatch): + """Windows dir paths and ``module:attr`` entry points used to collapse to ``.`` and scan the + launch directory; an entry point must resolve to its installed package, everything else to None.""" + monkeypatch.setattr(pc, "load_manifest", lambda: MANIFEST) + monkeypatch.chdir(tmp_path) + (tmp_path / "stray.py").write_text("from tools.web_tools import prefers_gateway\n") + assert pc.plugin_hits(SimpleNamespace(source="directory", path=r"C:\Users\alice\plugin", name="w")) == [] + assert pc.plugin_hits(SimpleNamespace(source="entrypoint", path="no_such_pkg_xyz:register", name="e")) == [] + pkg = tmp_path / "site" / "vendor_plugin"; pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("from tools.web_tools import prefers_gateway\n") + monkeypatch.syspath_prepend(str(tmp_path / "site")) + hits = pc.plugin_hits(SimpleNamespace(source="entrypoint", path="vendor_plugin:register", name="v")) + assert [h.file for h in hits] == ["__init__.py"] + + +def test_allow_override_requires_literal_true_and_notice_reports_it(monkeypatch): + assert pc.allow_deprecated_imports({"plugins": {pc.ALLOW_KEY: "false"}}) is False + assert pc.allow_deprecated_imports({"plugins": {pc.ALLOW_KEY: 1}}) is False + assert pc.allow_deprecated_imports({"plugins": {pc.ALLOW_KEY: True}}) is True + report = {"bad": [pc.Hit("x.py", 1, "tools.web_tools.prefers_gateway", "tools.tool_backend_helpers.prefers_gateway")]} + after = pc.COMPAT_REMOVAL_DATE + monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: True) + assert "force-loaded" in pc.summary_lines(report, today=after)[0] + monkeypatch.setattr(pc, "allow_deprecated_imports", lambda config=None: False) + assert "DISABLED" in pc.summary_lines(report, today=after)[0]