Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""Regression coverage for safe automatic profile-export destinations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from argparse import Namespace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from hermes_cli import profile_cmd
|
|
|
|
|
|
@pytest.fixture()
|
|
def profiles():
|
|
"""Resolve the live profiles module at call time.
|
|
|
|
Sibling test files reload ``hermes_cli.profiles`` / ``hermes_cli.main``;
|
|
a top-level ``from hermes_cli.profiles import ...`` here would bind the
|
|
pre-reload function objects and silently divorce this file's monkeypatches
|
|
from the code under test when the whole directory runs as one sweep.
|
|
"""
|
|
return importlib.import_module("hermes_cli.profiles")
|
|
|
|
|
|
def test_default_export_path_is_managed_and_outside_named_profiles(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
|
|
result = profiles.get_profile_export_path(
|
|
"Research-Bot", timestamp="20260823-120000"
|
|
)
|
|
|
|
assert (
|
|
result
|
|
== default_home / "profile-exports" / "research-bot-20260823-120000.tar.gz"
|
|
)
|
|
assert result.parent.is_dir()
|
|
assert not (default_home / "profiles" / "research-bot" / result.name).exists()
|
|
|
|
|
|
def test_custom_hermes_home_inside_a_checkout_uses_a_sibling_store(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
checkout = tmp_path / "checkout"
|
|
checkout.mkdir()
|
|
(checkout / ".git").write_text("gitdir: ../git\n", encoding="utf-8")
|
|
monkeypatch.chdir(checkout)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: checkout)
|
|
|
|
result = profiles.get_profile_export_path("default", timestamp="20260823-120000")
|
|
|
|
assert not result.resolve().is_relative_to(checkout.resolve())
|
|
|
|
|
|
def test_checkout_detection_does_not_depend_on_cwd(tmp_path, monkeypatch, profiles):
|
|
"""HERMES_HOME inside a checkout must be detected even when cwd is elsewhere.
|
|
|
|
Regression for the cwd-anchored bypass: cron/service-manager invocations
|
|
run from outside the checkout, and the original heuristic walked
|
|
``Path.cwd()`` — letting the export land inside the source tree again.
|
|
"""
|
|
checkout = tmp_path / "checkout"
|
|
checkout.mkdir()
|
|
(checkout / ".git").write_text("gitdir: ../git\n", encoding="utf-8")
|
|
hermes_home = checkout / "data_home"
|
|
hermes_home.mkdir()
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
monkeypatch.chdir(elsewhere) # cwd has no .git ancestor inside tmp_path
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: hermes_home)
|
|
|
|
result = profiles.get_profile_export_path("default", timestamp="20260823-120000")
|
|
|
|
assert not result.resolve().is_relative_to(checkout.resolve())
|
|
|
|
|
|
def test_cli_export_rejects_bad_profile_name_without_traceback(
|
|
tmp_path, monkeypatch, capsys, profiles
|
|
):
|
|
"""A bad name must print a clean error — the helper raises before export."""
|
|
main_mod = importlib.import_module("hermes_cli.main")
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
|
|
with pytest.raises(SystemExit):
|
|
profile_cmd.cmd_profile(
|
|
Namespace(
|
|
profile_action="export",
|
|
profile_name="bad//name",
|
|
output=None,
|
|
)
|
|
)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Error:" in out
|
|
|
|
|
|
def test_cli_export_default_does_not_write_into_the_current_checkout(
|
|
tmp_path, monkeypatch, capsys, profiles
|
|
):
|
|
main_mod = importlib.import_module("hermes_cli.main")
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
(default_home / "config.yaml").write_text("model: test\n", encoding="utf-8")
|
|
checkout = tmp_path / "checkout"
|
|
checkout.mkdir()
|
|
monkeypatch.chdir(checkout)
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
monkeypatch.setattr(
|
|
"hermes_constants.get_default_hermes_root", lambda: default_home
|
|
)
|
|
|
|
profile_cmd.cmd_profile(
|
|
Namespace(
|
|
profile_action="export",
|
|
profile_name="default",
|
|
output=None,
|
|
)
|
|
)
|
|
|
|
exported = list((default_home / "profile-exports").glob("default-*.tar.gz"))
|
|
assert len(exported) == 1
|
|
assert exported[0].parent == default_home / "profile-exports"
|
|
assert not (checkout / "default.tar.gz").exists()
|
|
assert str(exported[0]) in capsys.readouterr().out
|
|
|
|
|
|
def test_slash_export_uses_the_same_managed_destination(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
mixin_mod = importlib.import_module("hermes_cli.cli_commands_mixin")
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
profiles,
|
|
"export_profile",
|
|
lambda name, output: calls.append((name, output)) or output,
|
|
)
|
|
|
|
mixin_mod.CLICommandsMixin()._handle_export_command("/export")
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0][0] == "default"
|
|
assert Path(calls[0][1]).parent == default_home / "profile-exports"
|
|
assert not (tmp_path / "default.tar.gz").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_profile_export_api_uses_the_shared_managed_destination(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
from hermes_cli.web_models import ProfileExport
|
|
|
|
router_mod = importlib.import_module("hermes_cli.web_routers.profiles")
|
|
|
|
managed = tmp_path / "profile-exports" / "default-20260823-120000.tar.gz"
|
|
monkeypatch.setattr(profiles, "get_profile_export_path", lambda name: managed)
|
|
monkeypatch.setattr(
|
|
profiles,
|
|
"export_profile",
|
|
lambda name, output, extra_files=None: output,
|
|
)
|
|
|
|
result = await router_mod.export_profile_endpoint("default", ProfileExport())
|
|
|
|
assert result == {"ok": True, "archive": str(managed)}
|
|
|
|
|
|
def test_cwd_in_unrelated_checkout_does_not_prove_safety(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
"""cwd inside unrelated checkout A must not stand in for the safety proof
|
|
of a HERMES_HOME inside checkout B."""
|
|
checkout_b = tmp_path / "checkout-b"
|
|
checkout_b.mkdir()
|
|
(checkout_b / ".git").mkdir()
|
|
checkout_a = tmp_path / "checkout-a"
|
|
checkout_a.mkdir()
|
|
(checkout_a / ".git").write_text("gitdir: ../git\n", encoding="utf-8")
|
|
monkeypatch.chdir(checkout_a)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: checkout_b)
|
|
|
|
result = profiles.get_profile_export_path("default", timestamp="20260823-120000")
|
|
|
|
assert not result.resolve().is_relative_to(checkout_b.resolve())
|
|
assert not result.resolve().is_relative_to(checkout_a.resolve())
|
|
|
|
|
|
def test_every_candidate_inside_a_checkout_fails_closed(
|
|
tmp_path, monkeypatch, profiles
|
|
):
|
|
"""When home, sibling store, and tempdir all resolve inside checkouts the
|
|
helper must refuse — a warning would not stop a scripted export from
|
|
recreating the #92457 incident artifact."""
|
|
import tempfile
|
|
|
|
checkout = tmp_path / "checkout"
|
|
checkout.mkdir()
|
|
(checkout / ".git").mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: checkout / "home")
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: checkout)
|
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(checkout / "tmp"))
|
|
|
|
with pytest.raises(ValueError, match="No safe automatic export destination"):
|
|
profiles.get_profile_export_path("default")
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_export_dir_symlink_is_rejected(tmp_path, monkeypatch, profiles):
|
|
"""A pre-created symlink at the managed export path (predictable-path
|
|
attack on shared hosts) must be refused, not silently followed."""
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
(default_home / "profile-exports").symlink_to(elsewhere)
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
profiles.get_profile_export_path("default")
|