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.
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Exact-commit plugin installation and source metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from hermes_cli.subcommands.plugins import build_plugins_parser
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args], cwd=repo, check=True, capture_output=True, text=True
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _commit(repo: Path, message: str, marker: str) -> str:
|
|
(repo / "marker.txt").write_text(marker, encoding="utf-8")
|
|
_git(repo, "add", ".")
|
|
_git(repo, "commit", "-qm", message)
|
|
return _git(repo, "rev-parse", "HEAD")
|
|
|
|
|
|
def _plugin_repo(root: Path, name: str = "demo") -> tuple[Path, str, str]:
|
|
repo = root / "repo"
|
|
repo.mkdir()
|
|
_git(repo, "init", "-q")
|
|
_git(repo, "config", "user.email", "fixture@example.com")
|
|
_git(repo, "config", "user.name", "Fixture")
|
|
(repo / "plugin.yaml").write_text(
|
|
yaml.safe_dump({"name": name, "version": "1.0.0"}), encoding="utf-8"
|
|
)
|
|
old_sha = _commit(repo, "old", "old")
|
|
new_sha = _commit(repo, "new", "new")
|
|
return repo, old_sha, new_sha
|
|
|
|
|
|
def _metadata(home: Path) -> dict:
|
|
return json.loads((home / "plugins" / ".install-metadata.json").read_text())
|
|
|
|
|
|
def test_parser_accepts_only_explicit_install_ref_option():
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
build_plugins_parser(subparsers, cmd_plugins=lambda _args: None)
|
|
|
|
args = parser.parse_args(["plugins", "install", "owner/repo", "--ref", "a" * 40])
|
|
|
|
assert args.ref == "a" * 40
|
|
|
|
|
|
def test_canonical_source_never_persists_http_credentials():
|
|
from hermes_cli.plugins_cmd import _canonical_source
|
|
|
|
assert (
|
|
_canonical_source("https://user:token@example.com/owner/repo.git", None)
|
|
== "https://example.com/owner/repo.git"
|
|
)
|
|
assert (
|
|
_canonical_source("https://example.com/owner/repo.git?token=secret", None)
|
|
== "https://example.com/owner/repo.git"
|
|
)
|
|
|
|
|
|
def test_cloned_origin_never_persists_http_credentials(tmp_path):
|
|
from hermes_cli.plugins_cmd import _scrub_cloned_origin
|
|
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
_git(repo, "init", "-q")
|
|
_git(
|
|
repo,
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://user:secret@example.com/owner/repo.git?token=secret",
|
|
)
|
|
|
|
_scrub_cloned_origin(
|
|
repo,
|
|
"git",
|
|
"https://user:secret@example.com/owner/repo.git?token=secret",
|
|
)
|
|
|
|
assert _git(repo, "remote", "get-url", "origin") == (
|
|
"https://example.com/owner/repo.git"
|
|
)
|
|
assert "secret" not in (repo / ".git" / "config").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_git_errors_never_echo_source_credentials():
|
|
from hermes_cli.plugins_cmd import _safe_git_error
|
|
|
|
source = "https://user:secret@example.com/owner/repo.git?token=secret"
|
|
result = subprocess.CompletedProcess(
|
|
args=["git", "clone"],
|
|
returncode=1,
|
|
stdout="",
|
|
stderr=f"fatal: unable to access '{source}': connection failed",
|
|
)
|
|
|
|
error = _safe_git_error(result, source)
|
|
|
|
assert "secret" not in error
|
|
assert "user:" not in error
|
|
assert "https://example.com/owner/repo.git" in error
|
|
|
|
|
|
def test_exact_ref_installs_old_commit_and_normalizes_uppercase(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
|
|
|
repo, old_sha, new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
target, _manifest, name = _install_plugin_core(
|
|
repo.as_uri(), force=False, ref=old_sha.upper()
|
|
)
|
|
|
|
assert name == "demo"
|
|
assert _git(target, "rev-parse", "HEAD") == old_sha
|
|
assert old_sha != new_sha
|
|
assert (target / "marker.txt").read_text() == "old"
|
|
assert _metadata(home) == {
|
|
"demo": {"pinned": True, "revision": old_sha, "source": repo.as_uri()}
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["", "main", "abc", "g" * 40, "a" * 39, "a" * 41])
|
|
def test_invalid_ref_is_rejected_before_any_install_state(monkeypatch, tmp_path, ref):
|
|
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
|
|
|
|
repo, _old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
with pytest.raises(PluginOperationError, match="40-character commit SHA"):
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=ref)
|
|
|
|
assert not (home / "plugins" / "demo").exists()
|
|
assert not (home / "plugins" / ".install-metadata.json").exists()
|
|
|
|
|
|
def test_subdir_pin_records_source_identity_and_installs_requested_tree(
|
|
monkeypatch, tmp_path
|
|
):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
|
|
|
repo = tmp_path / "monorepo"
|
|
plugin = repo / "extensions" / "demo"
|
|
plugin.mkdir(parents=True)
|
|
_git(repo, "init", "-q")
|
|
_git(repo, "config", "user.email", "fixture@example.com")
|
|
_git(repo, "config", "user.name", "Fixture")
|
|
(plugin / "plugin.yaml").write_text("name: nested-demo\n", encoding="utf-8")
|
|
(plugin / "value.txt").write_text("old", encoding="utf-8")
|
|
_git(repo, "add", ".")
|
|
_git(repo, "commit", "-qm", "old")
|
|
old_sha = _git(repo, "rev-parse", "HEAD")
|
|
(plugin / "value.txt").write_text("new", encoding="utf-8")
|
|
_git(repo, "commit", "-qam", "new")
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
identifier = f"{repo.as_uri()}#extensions/demo"
|
|
|
|
target, _manifest, _name = _install_plugin_core(
|
|
identifier, force=False, ref=old_sha
|
|
)
|
|
|
|
assert (target / "value.txt").read_text() == "old"
|
|
assert _metadata(home)["nested-demo"] == {
|
|
"pinned": True,
|
|
"revision": old_sha,
|
|
"source": identifier,
|
|
}
|
|
|
|
|
|
def test_force_reinstall_does_not_drift_pin_without_explicit_new_ref(
|
|
monkeypatch, tmp_path
|
|
):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
|
|
|
repo, old_sha, new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
|
|
|
|
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=True)
|
|
assert _git(target, "rev-parse", "HEAD") == old_sha
|
|
assert _metadata(home)["demo"]["pinned"] is True
|
|
|
|
target, _manifest, _name = _install_plugin_core(
|
|
repo.as_uri(), force=True, ref=new_sha
|
|
)
|
|
assert _git(target, "rev-parse", "HEAD") == new_sha
|
|
assert _metadata(home)["demo"]["revision"] == new_sha
|
|
|
|
|
|
def test_unpinned_install_and_force_reinstall_keep_tracking_head(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
|
|
|
repo, _old_sha, first_head = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
|
|
assert _git(target, "rev-parse", "HEAD") == first_head
|
|
assert _metadata(home)["demo"]["pinned"] is False
|
|
|
|
next_head = _commit(repo, "later", "later")
|
|
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=True)
|
|
assert _git(target, "rev-parse", "HEAD") == next_head
|
|
assert _metadata(home)["demo"]["revision"] == next_head
|
|
assert _metadata(home)["demo"]["pinned"] is False
|
|
|
|
|
|
def test_metadata_is_profile_local_and_read_from_disk_each_time(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core, _read_install_metadata
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home_a = tmp_path / "profile-a"
|
|
home_b = tmp_path / "profile-b"
|
|
monkeypatch.setenv("HERMES_HOME", str(home_a))
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
|
|
assert _read_install_metadata()["demo"]["revision"] == old_sha
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home_b))
|
|
assert _read_install_metadata() == {}
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home_a))
|
|
assert _read_install_metadata()["demo"]["source"] == repo.as_uri()
|
|
|
|
|
|
def test_pinned_plugin_update_refuses_to_drift(monkeypatch, tmp_path, capsys):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core, cmd_update
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
cmd_update("demo")
|
|
|
|
assert exc.value.code == 1
|
|
assert "pinned" in capsys.readouterr().out.lower()
|
|
assert _git(home / "plugins" / "demo", "rev-parse", "HEAD") == old_sha
|
|
|
|
|
|
def test_dashboard_update_also_refuses_to_drift_pin(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import (
|
|
_install_plugin_core,
|
|
dashboard_update_user_plugin,
|
|
)
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
|
|
|
|
result = dashboard_update_user_plugin("demo")
|
|
|
|
assert result["ok"] is False
|
|
assert "pinned" in result["error"]
|
|
assert _git(home / "plugins" / "demo", "rev-parse", "HEAD") == old_sha
|
|
|
|
|
|
def test_failed_force_reinstall_keeps_existing_plugin_and_metadata(
|
|
monkeypatch, tmp_path
|
|
):
|
|
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
target, _manifest, _name = _install_plugin_core(
|
|
repo.as_uri(), force=False, ref=old_sha
|
|
)
|
|
before = _metadata(home)
|
|
|
|
with pytest.raises(PluginOperationError):
|
|
_install_plugin_core(repo.as_uri(), force=True, ref="f" * 40)
|
|
|
|
assert target.exists()
|
|
assert _git(target, "rev-parse", "HEAD") == old_sha
|
|
assert _metadata(home) == before
|
|
|
|
|
|
def test_checkout_mismatch_is_rejected(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import PluginOperationError, _checkout_exact_revision
|
|
|
|
repo, old_sha, new_sha = _plugin_repo(tmp_path)
|
|
clone = tmp_path / "clone"
|
|
subprocess.run(["git", "clone", "-q", repo.as_uri(), str(clone)], check=True)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins_cmd._git_head_revision", lambda _repo, _git: new_sha
|
|
)
|
|
|
|
with pytest.raises(PluginOperationError, match="does not match requested"):
|
|
_checkout_exact_revision(clone, "git", old_sha)
|
|
|
|
|
|
def test_metadata_write_failure_rolls_back_new_install(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins_cmd._write_install_metadata",
|
|
lambda _metadata: (_ for _ in ()).throw(OSError("disk full")),
|
|
)
|
|
|
|
with pytest.raises(OSError, match="disk full"):
|
|
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
|
|
|
|
assert not (home / "plugins" / "demo").exists()
|
|
assert not (home / "plugins" / ".install-metadata.json").exists()
|
|
|
|
|
|
def test_metadata_write_failure_rolls_back_removal(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core, _remove_plugin_core
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
target, _manifest, _name = _install_plugin_core(
|
|
repo.as_uri(), force=False, ref=old_sha
|
|
)
|
|
before = _metadata(home)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins_cmd._write_install_metadata",
|
|
lambda _metadata: (_ for _ in ()).throw(OSError("disk full")),
|
|
)
|
|
|
|
with pytest.raises(OSError, match="disk full"):
|
|
_remove_plugin_core(target)
|
|
|
|
assert target.exists()
|
|
assert _git(target, "rev-parse", "HEAD") == old_sha
|
|
assert _metadata(home) == before
|
|
assert list(target.parent.glob(".demo.remove-*")) == []
|
|
|
|
|
|
def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_path):
|
|
from hermes_cli.plugins_cmd import _install_plugin_core, _rmtree_force
|
|
|
|
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
target, _manifest, _name = _install_plugin_core(
|
|
repo.as_uri(), force=False, ref=old_sha
|
|
)
|
|
_rmtree_force(target)
|
|
|
|
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
|
|
|
|
assert _git(target, "rev-parse", "HEAD") == old_sha
|
|
assert _metadata(home)["demo"]["pinned"] is True
|