Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""Tests for PR1 pluggable image gen: scanner recursion, kinds, path keys.
|
|
|
|
Covers ``_scan_directory`` recursion into category namespaces
|
|
(``plugins/image_gen/openai/``), ``kind`` parsing, path-derived registry
|
|
keys, and the new gate logic (bundled backends auto-load; user backends
|
|
still opt-in; exclusive kind skipped; unknown kinds → standalone warning).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from hermes_cli.plugins import PluginManager
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _write_plugin(
|
|
root: Path,
|
|
segments: list[str],
|
|
*,
|
|
manifest_extra: Dict[str, Any] | None = None,
|
|
register_body: str = "pass",
|
|
) -> Path:
|
|
"""Create a plugin dir at ``root/<segments...>/`` with plugin.yaml + __init__.py.
|
|
|
|
``segments`` lets tests build both flat (``["my-plugin"]``) and
|
|
category-namespaced (``["image_gen", "openai"]``) layouts.
|
|
"""
|
|
plugin_dir = root
|
|
for seg in segments:
|
|
plugin_dir = plugin_dir / seg
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
manifest = {
|
|
"name": segments[-1],
|
|
"version": "0.1.0",
|
|
"description": f"Test plugin {'/'.join(segments)}",
|
|
}
|
|
if manifest_extra:
|
|
manifest.update(manifest_extra)
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest))
|
|
(plugin_dir / "__init__.py").write_text(
|
|
f"def register(ctx):\n {register_body}\n"
|
|
)
|
|
return plugin_dir
|
|
|
|
|
|
def _enable(hermes_home: Path, name: str) -> None:
|
|
"""Append ``name`` to ``plugins.enabled`` in ``<hermes_home>/config.yaml``."""
|
|
cfg_path = hermes_home / "config.yaml"
|
|
cfg: dict = {}
|
|
if cfg_path.exists():
|
|
try:
|
|
cfg = yaml.safe_load(cfg_path.read_text()) or {}
|
|
except Exception:
|
|
cfg = {}
|
|
plugins_cfg = cfg.setdefault("plugins", {})
|
|
enabled = plugins_cfg.setdefault("enabled", [])
|
|
if isinstance(enabled, list) and name not in enabled:
|
|
enabled.append(name)
|
|
cfg_path.write_text(yaml.safe_dump(cfg))
|
|
|
|
|
|
# ── Scanner recursion ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCategoryNamespaceRecursion:
|
|
def test_category_namespace_discovered(self, tmp_path, monkeypatch):
|
|
"""``<root>/image_gen/openai/plugin.yaml`` is discovered with key
|
|
``image_gen/openai`` when the ``image_gen`` parent has no manifest."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(user_plugins, ["image_gen", "openai"])
|
|
_enable(hermes_home, "image_gen/openai")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "image_gen/openai" in mgr._plugins
|
|
loaded = mgr._plugins["image_gen/openai"]
|
|
assert loaded.manifest.key == "image_gen/openai"
|
|
assert loaded.manifest.name == "openai"
|
|
assert loaded.enabled is True
|
|
|
|
|
|
def test_depth_cap_two(self, tmp_path, monkeypatch):
|
|
"""Plugins nested three levels deep are not discovered.
|
|
|
|
``<root>/a/b/c/plugin.yaml`` should NOT be picked up — cap is
|
|
two segments.
|
|
"""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(user_plugins, ["a", "b", "c"])
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
non_bundled = [
|
|
k for k, p in mgr._plugins.items()
|
|
if p.manifest.source != "bundled"
|
|
]
|
|
assert non_bundled == []
|
|
|
|
|
|
|
|
# ── Kind parsing ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestKindField:
|
|
def test_default_kind_is_standalone(self, tmp_path, monkeypatch):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(hermes_home / "plugins", ["p1"])
|
|
_enable(hermes_home, "p1")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["p1"].manifest.kind == "standalone"
|
|
|
|
@pytest.mark.parametrize("kind", ["backend", "exclusive", "standalone"])
|
|
def test_valid_kinds_parsed(self, kind, tmp_path, monkeypatch):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["p1"],
|
|
manifest_extra={"kind": kind},
|
|
)
|
|
# Not all kinds auto-load, but manifest should parse.
|
|
_enable(hermes_home, "p1")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "p1" in mgr._plugins
|
|
assert mgr._plugins["p1"].manifest.kind == kind
|
|
|
|
def test_unknown_kind_falls_back_to_standalone(self, tmp_path, monkeypatch, caplog):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["p1"],
|
|
manifest_extra={"kind": "bogus"},
|
|
)
|
|
_enable(hermes_home, "p1")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["p1"].manifest.kind == "standalone"
|
|
assert any(
|
|
"unknown kind" in rec.getMessage() for rec in caplog.records
|
|
)
|
|
|
|
|
|
# ── Gate logic ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBackendGate:
|
|
def test_user_backend_still_gated_by_enabled(self, tmp_path, monkeypatch):
|
|
"""User-installed ``kind: backend`` plugins still require opt-in —
|
|
they're not trusted by default."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(
|
|
user_plugins,
|
|
["image_gen", "fancy"],
|
|
manifest_extra={"kind": "backend"},
|
|
)
|
|
# Do NOT opt in.
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
loaded = mgr._plugins["image_gen/fancy"]
|
|
assert loaded.enabled is False
|
|
assert "not enabled" in (loaded.error or "")
|
|
|
|
|
|
def test_exclusive_kind_skipped(self, tmp_path, monkeypatch):
|
|
"""``kind: exclusive`` plugins are recorded but not loaded — the
|
|
category's own discovery system handles them (memory today)."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["some-backend"],
|
|
manifest_extra={"kind": "exclusive"},
|
|
)
|
|
_enable(hermes_home, "some-backend")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
loaded = mgr._plugins["some-backend"]
|
|
assert loaded.enabled is False
|
|
assert "exclusive" in (loaded.error or "")
|
|
|
|
|
|
# ── Bundled backend auto-load (integration with real bundled plugin) ────────
|
|
|
|
|
|
class TestBundledBackendAutoLoad:
|
|
def test_bundled_image_gen_openai_autoloads(self, tmp_path, monkeypatch):
|
|
"""The bundled ``plugins/image_gen/openai/`` plugin loads without
|
|
any opt-in — it's ``kind: backend`` and shipped in-repo."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "image_gen/openai" in mgr._plugins
|
|
loaded = mgr._plugins["image_gen/openai"]
|
|
assert loaded.manifest.source == "bundled"
|
|
assert loaded.manifest.kind == "backend"
|
|
assert loaded.enabled is True, f"error: {loaded.error}"
|
|
|
|
|
|
# ── PluginContext.register_image_gen_provider ───────────────────────────────
|
|
|
|
|
|
class TestRegisterImageGenProvider:
|
|
def test_accepts_valid_provider(self, tmp_path, monkeypatch):
|
|
from agent import image_gen_registry
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
class FakeProvider(ImageGenProvider):
|
|
@property
|
|
def name(self) -> str:
|
|
return "fake-test"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kw):
|
|
return {"success": True, "image": "test://fake"}
|
|
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
plugin_dir = _write_plugin(
|
|
hermes_home / "plugins",
|
|
["my-img-plugin"],
|
|
register_body=(
|
|
"from agent.image_gen_provider import ImageGenProvider\n"
|
|
" class P(ImageGenProvider):\n"
|
|
" @property\n"
|
|
" def name(self): return 'fake-ctx'\n"
|
|
" def generate(self, prompt, aspect_ratio='landscape', **kw):\n"
|
|
" return {'success': True, 'image': 'x://y'}\n"
|
|
" ctx.register_image_gen_provider(P())"
|
|
),
|
|
)
|
|
_enable(hermes_home, "my-img-plugin")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["my-img-plugin"].enabled is True
|
|
assert image_gen_registry.get_provider("fake-ctx") is not None
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
def test_rejects_non_provider(self, tmp_path, monkeypatch, caplog):
|
|
from agent import image_gen_registry
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["bad-img-plugin"],
|
|
register_body="ctx.register_image_gen_provider('not a provider')",
|
|
)
|
|
_enable(hermes_home, "bad-img-plugin")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
# Plugin loaded (register returned normally) but nothing was
|
|
# registered in the provider registry.
|
|
assert mgr._plugins["bad-img-plugin"].enabled is True
|
|
assert image_gen_registry.get_provider("not a provider") is None
|
|
|
|
image_gen_registry._reset_for_tests()
|