Files
hermes-agent/tests/hermes_cli/test_profile_distribution.py
kshitijk4poor 436b904e80 fix(profile_distribution): a dir inside a skill stays part of that skill
An owned path like skills/research/web-search/scripts has no SKILL.md of
its own, so the category rule merged it and left retired files behind.
Look for SKILL.md in the dir and its parents below skills/.
2026-09-27 02:42:27 +05:30

980 lines
44 KiB
Python

"""Tests for hermes_cli.profile_distribution — git-based profile installs.
Covers manifest parsing, version requirement checks, install / update / describe
on local-directory sources, and guards on what can and can't be installed.
Transport-layer tests (git clone, URL handling) are exercised through live
E2E runs, not unit tests — git itself is tested upstream, and subprocess-
mocking git would just test the mock.
"""
from __future__ import annotations
import json
import shutil
import stat
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from hermes_cli.profile_distribution import (
DEFAULT_DIST_OWNED,
DistributionError,
DistributionManifest,
EnvRequirement,
MANIFEST_FILENAME,
_env_template_from_manifest,
_looks_like_git_url,
_parse_semver,
_stage_source,
check_hermes_requires,
describe_distribution,
install_distribution,
plan_install,
read_manifest,
update_distribution,
write_manifest,
)
# ---------------------------------------------------------------------------
# Isolated profile env (matches tests/hermes_cli/test_profiles.py)
# ---------------------------------------------------------------------------
@pytest.fixture()
def profile_env(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
default_home = tmp_path / ".hermes"
default_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(default_home))
return tmp_path
def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionManifest = None) -> Path:
"""Build a local distribution staging directory (what a git clone would
contain after .git is removed).
Lays down a minimal but representative tree: SOUL.md, config.yaml,
mcp.json, one skill, one cron job, plus the distribution.yaml manifest.
"""
staged = root / f"staging_{name}"
staged.mkdir(parents=True, exist_ok=True)
(staged / "SOUL.md").write_text("I am Source.\n")
(staged / "config.yaml").write_text("model:\n model: gpt-4\n")
(staged / "mcp.json").write_text('{"servers": {}}\n')
(staged / "skills").mkdir(exist_ok=True)
(staged / "skills" / "demo").mkdir(exist_ok=True)
(staged / "skills" / "demo" / "SKILL.md").write_text(
"---\nname: demo\ndescription: test\n---\n# Demo skill\n"
)
from cron.jobs import create_job, use_cron_store
with use_cron_store(staged):
create_job("daily task", "0 9 * * *", name="daily")
mf = manifest or DistributionManifest(name=name, version="0.1.0")
write_manifest(staged, mf)
return staged
def _symlink_file_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError as exc:
pytest.skip(f"symlinks unavailable in test environment: {exc}")
# ===========================================================================
# Manifest parsing
# ===========================================================================
class TestManifestParsing:
def test_full_manifest(self, tmp_path):
(tmp_path / MANIFEST_FILENAME).write_text(
"name: telem\n"
"version: 1.2.3\n"
"description: Telem monitor\n"
"hermes_requires: '>=0.12.0'\n"
"author: Kyle\n"
"license: MIT\n"
"env_requires:\n"
" - name: OPENAI_API_KEY\n"
" description: OpenAI key\n"
" - name: GRAPH_URL\n"
" required: false\n"
" default: http://127.0.0.1:8000\n"
"distribution_owned:\n"
" - SOUL.md\n"
" - skills/\n"
)
m = read_manifest(tmp_path)
assert m.name == "telem"
assert m.version == "1.2.3"
assert m.author == "Kyle"
assert m.license == "MIT"
assert len(m.env_requires) == 2
assert m.env_requires[0].name == "OPENAI_API_KEY"
assert m.env_requires[0].required is True
assert m.env_requires[1].required is False
assert m.env_requires[1].default == "http://127.0.0.1:8000"
assert m.distribution_owned == ["SOUL.md", "skills"]
def test_roundtrip_write_read(self, tmp_path):
original = DistributionManifest(
name="rt",
version="1.0.0",
description="roundtrip",
env_requires=[EnvRequirement(name="FOO", description="foo")],
)
write_manifest(tmp_path, original)
parsed = read_manifest(tmp_path)
assert parsed.name == "rt"
assert parsed.env_requires[0].name == "FOO"
# ===========================================================================
# Version requirement checks
# ===========================================================================
class TestVersionRequires:
@pytest.mark.parametrize("spec,cur,ok", [
("", "0.1.0", True),
(">=0.12.0", "0.12.0", True),
(">=0.12.0", "0.13.0", True),
(">=0.12.0", "0.11.9", False),
("==0.12.0", "0.12.0", True),
("==0.12.0", "0.13.0", False),
("!=0.12.0", "0.13.0", True),
(">0.12.0", "0.12.1", True),
(">0.12.0", "0.12.0", False),
("<0.13.0", "0.12.9", True),
("<=0.12.0", "0.12.0", True),
("0.12.0", "0.13.0", True), # Bare = >=
("0.12.0", "0.11.0", False), # Bare = >=
])
def test_check_matrix(self, spec, cur, ok):
if ok:
check_hermes_requires(spec, cur)
else:
with pytest.raises(DistributionError, match="requires Hermes"):
check_hermes_requires(spec, cur)
def test_parse_semver_handles_prerelease(self):
assert _parse_semver("0.12.0-rc1") == (0, 12, 0)
assert _parse_semver("v0.12.0+abc") == (0, 12, 0)
# ===========================================================================
# Env template
# ===========================================================================
class TestEnvTemplate:
def test_required_is_uncommented(self):
m = DistributionManifest(
name="x",
env_requires=[EnvRequirement(name="FOO", description="foo key")],
)
out = _env_template_from_manifest(m)
assert "# foo key" in out
assert "# (required)" in out
assert "FOO=" in out
# No leading `# ` before FOO=
assert "\nFOO=" in out or out.startswith("FOO=") or "\nFOO=\n" in out or "FOO=\n" in out
# ===========================================================================
# Source URL detection
# ===========================================================================
class TestLooksLikeGitUrl:
@pytest.mark.parametrize("src", [
"github.com/user/repo",
"https://github.com/user/repo",
"https://github.com/user/repo.git",
"http://example.com/repo",
"git@github.com:user/repo.git",
"ssh://git@example.com/repo.git",
"git://example.com/repo.git",
])
def test_accepts_git_sources(self, src):
assert _looks_like_git_url(src)
@pytest.mark.platforms("windows")
def test_git_source_removes_read_only_git_metadata(self, tmp_path, monkeypatch):
origin = tmp_path / "origin"
subprocess.run(["git", "init", "--quiet", str(origin)], check=True)
(origin / MANIFEST_FILENAME).write_text("name: demo\nversion: 1.0.0\n", encoding="utf-8")
subprocess.run(["git", "-C", str(origin), "add", "."], check=True)
subprocess.run([
"git", "-C", str(origin), "-c", "user.name=Test", "-c",
"user.email=test@example.invalid", "commit", "--quiet", "-m", "init",
], check=True)
saw_read_only_objects = []
def clone_local(_url, dest):
subprocess.run(["git", "clone", "--quiet", str(origin), str(dest)], check=True)
objects = [path for path in (dest / ".git" / "objects").rglob("*") if path.is_file()]
saw_read_only_objects.append(
bool(objects) and any(
path.stat().st_file_attributes & stat.FILE_ATTRIBUTE_READONLY for path in objects
)
)
monkeypatch.setattr("hermes_cli.profile_distribution._git_clone", clone_local)
workdir = tmp_path / "work"
workdir.mkdir()
staged, _provenance = _stage_source("https://example.invalid/demo.git", workdir)
assert saw_read_only_objects == [True]
assert not (staged / ".git").exists()
# ===========================================================================
# Install — fresh and force (from a local-directory source)
# ===========================================================================
class TestInstall:
def test_install_from_directory(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="installed")
assert plan.target_dir.is_dir()
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists()
assert (plan.target_dir / "mcp.json").exists()
# Manifest on disk records canonical name + provenance
m = read_manifest(plan.target_dir)
assert m.name == "installed"
assert m.source == str(staged)
def test_install_respects_distribution_owned_allowlist(self, profile_env):
"""Install must only copy paths listed in distribution_owned."""
mf = DistributionManifest(
name="restricted",
version="0.1.0",
distribution_owned=["SOUL.md", "skills"],
)
staged = _make_staging_dir(profile_env, "restricted", manifest=mf)
# Confirm extra files exist in staging
assert (staged / "mcp.json").exists(), "mcp.json should exist in staged for this test"
assert (staged / "cron").is_dir(), "cron/ should exist in staged for this test"
plan = install_distribution(str(staged), name="restricted")
# Owned paths must be present
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
assert (plan.target_dir / "skills").is_dir()
assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists()
# NOT-owned paths must NOT be copied from staging
assert not (plan.target_dir / "mcp.json").exists(), \
"mcp.json should NOT be copied (not in distribution_owned)"
# cron/ is created by _bootstrap_user_dirs, but the staged cron/ content
# must NOT leak through
if (plan.target_dir / "cron").exists():
cron_content = list((plan.target_dir / "cron").iterdir())
assert not cron_content, \
f"cron/ should be empty (staged content skipped): {cron_content}"
# distribution.yaml is always written by write_manifest
assert (plan.target_dir / "distribution.yaml").exists()
def test_install_omitted_allowlist_copies_everything(self, profile_env):
"""Legacy contract: when distribution_owned is OMITTED, every staged
entry outside USER_OWNED_EXCLUDE is copied — the omitted list must NOT
silently narrow to DEFAULT_DIST_OWNED."""
staged = _make_staging_dir(profile_env, "legacy_all")
# Extra top-level payload not covered by DEFAULT_DIST_OWNED
(staged / "extra.txt").write_text("bonus\n")
(staged / "assets").mkdir()
(staged / "assets" / "helper.py").write_text("# helper\n")
plan = install_distribution(str(staged), name="legacy_all")
assert (plan.target_dir / "extra.txt").read_text() == "bonus\n", \
"omitted distribution_owned must keep copying undeclared files"
assert (plan.target_dir / "assets" / "helper.py").exists(), \
"omitted distribution_owned must keep copying undeclared dirs"
def test_install_allowlist_supports_nested_paths(self, profile_env):
"""Nested skill paths and the canonical cron store can be allowlisted exactly."""
mf = DistributionManifest(
name="nested",
version="0.1.0",
distribution_owned=["SOUL.md", "skills/research/", "cron/jobs.json"],
)
staged = _make_staging_dir(profile_env, "nested", manifest=mf)
(staged / "skills" / "research").mkdir()
(staged / "skills" / "research" / "SKILL.md").write_text(
"---\nname: research\ndescription: r\n---\n# R\n"
)
plan = install_distribution(str(staged), name="nested")
assert (plan.target_dir / "skills" / "research" / "SKILL.md").exists()
assert (plan.target_dir / "cron" / "jobs.json").exists()
assert not (plan.target_dir / "skills" / "demo").exists(), \
"skills/demo is not allowlisted and must not be copied"
assert not (plan.target_dir / "mcp.json").exists()
def test_update_respects_distribution_owned_allowlist(self, profile_env):
"""Update must only copy paths listed in distribution_owned."""
# 1. Install with full default distribution_owned
staged = _make_staging_dir(profile_env, "up_src")
plan = install_distribution(str(staged), name="up_restricted")
assert (plan.target_dir / "mcp.json").exists(), "baseline: mcp.json should exist"
# 2. Write a new manifest with restricted distribution_owned
restricted_mf = DistributionManifest(
name="up_restricted",
version="0.2.0",
distribution_owned=["SOUL.md", "skills"],
)
write_manifest(staged, restricted_mf)
# Also add a NEW file in the staged dir that is NOT in distribution_owned
(staged / "new_config.toml").write_text("[extra]\n")
# The manifest on disk needs the new source to match
from hermes_cli.profile_distribution import read_manifest as _read
m_on_disk = _read(plan.target_dir)
m_on_disk.source = str(staged)
write_manifest(plan.target_dir, m_on_disk)
# 3. Update
update_distribution("up_restricted", force_config=True)
# 4. Owned paths should be updated
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
assert (plan.target_dir / "skills").is_dir()
# 5. Formerly-owned paths (mcp.json, cron/) should NOT be copied on update
# Note: mcp.json existed before so it stays (not removed). The guard is
# about what gets COPIED, not what's cleaned up.
assert not (plan.target_dir / "new_config.toml").exists(), \
"new_config.toml should not be copied (not in distribution_owned)"
def test_install_pauses_shipped_cron_jobs_and_skips_runtime_state(self, profile_env):
"""Distribution cron definitions are inert until the installer explicitly resumes them."""
from cron.jobs import get_due_jobs, is_job_runnable, list_jobs, update_job, use_cron_store
staged = _make_staging_dir(profile_env, "cron_src")
with use_cron_store(staged):
shipped = list_jobs(include_disabled=True)[0]
update_job(shipped["id"], {
"next_run_at": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
})
(staged / "cron" / "future-runtime.bin").write_text("runtime", encoding="utf-8")
(staged / "skills" / ".future-runtime").write_text("runtime", encoding="utf-8")
(staged / "skills" / "demo" / ".authored-hidden").write_text("keep", encoding="utf-8")
plan = install_distribution(str(staged), name="cron_paused")
with use_cron_store(plan.target_dir):
installed = {job["id"]: job for job in list_jobs(include_disabled=True)}
due = get_due_jobs()
job = installed[shipped["id"]]
assert not is_job_runnable(job)
assert job["state"] == "paused"
assert due == []
assert not (plan.target_dir / "cron" / "future-runtime.bin").exists()
assert not (plan.target_dir / "skills" / ".future-runtime").exists()
assert (plan.target_dir / "skills" / "demo" / ".authored-hidden").read_text() == "keep"
def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path):
bogus = tmp_path / "bogus_dir"
bogus.mkdir()
(bogus / "some_file").write_text("hi")
with pytest.raises(DistributionError, match="No distribution.yaml"):
plan_install(str(bogus), tmp_path / "work", override_name="x")
def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
# Pin current Hermes version to something well below the requirement
monkeypatch.setattr(
"hermes_cli.version_info.get_version_info",
lambda: SimpleNamespace(base_version="0.1.0"),
)
mf = DistributionManifest(
name="future",
version="1.0.0",
hermes_requires=">=99.0.0",
)
staged = _make_staging_dir(profile_env, "future", manifest=mf)
with pytest.raises(DistributionError, match="requires Hermes"):
install_distribution(str(staged), name="future")
# ===========================================================================
# Update — preserves user data, preserves config by default
# ===========================================================================
class TestUpdate:
def test_update_and_force_install_merge_owned_dirs_per_root(self, profile_env):
"""skills/ and cron/ are containers of roots: roots the payload ships are replaced
wholesale (retired files disappear), roots the user added survive both paths."""
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="skills_safe")
custom = plan.target_dir / "skills" / "custom"
custom.mkdir()
(custom / "SKILL.md").write_text("custom skill\n", encoding="utf-8")
(plan.target_dir / "cron" / "mine.json").write_text('{"schedule": "* * * * *"}\n', encoding="utf-8")
(plan.target_dir / "skills" / "demo" / "stale.txt").write_text("old file\n", encoding="utf-8")
(staged / "skills" / "demo" / "SKILL.md").write_text("updated demo\n", encoding="utf-8")
(staged / "skills" / "new").mkdir()
(staged / "skills" / "new" / "SKILL.md").write_text("new skill\n", encoding="utf-8")
# Categorised skill (skills/<category>/<skill>): the category is a container too,
# so a sibling the user added inside it survives (issue #25120's literal repro).
(staged / "skills" / "devops" / "team-deploy").mkdir(parents=True)
(staged / "skills" / "devops" / "team-deploy" / "SKILL.md").write_text("team deploy\n", encoding="utf-8")
mine = plan.target_dir / "skills" / "devops" / "my-custom-skill"
mine.mkdir(parents=True)
(mine / "SKILL.md").write_text("my custom skill\n", encoding="utf-8")
update_distribution("skills_safe")
assert (custom / "SKILL.md").read_text(encoding="utf-8") == "custom skill\n"
assert (mine / "SKILL.md").read_text(encoding="utf-8") == "my custom skill\n"
assert (plan.target_dir / "skills" / "devops" / "team-deploy" / "SKILL.md").exists()
assert (plan.target_dir / "cron" / "mine.json").read_text(encoding="utf-8") == '{"schedule": "* * * * *"}\n'
assert (plan.target_dir / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == "updated demo\n"
assert (plan.target_dir / "skills" / "new" / "SKILL.md").read_text(encoding="utf-8") == "new skill\n"
assert not (plan.target_dir / "skills" / "demo" / "stale.txt").exists()
install_distribution(str(staged), name="skills_safe", force=True)
assert (custom / "SKILL.md").read_text(encoding="utf-8") == "custom skill\n"
assert (plan.target_dir / "cron" / "mine.json").exists()
@staticmethod
def _owned_category(profile_env, name):
"""A distribution owning only ``skills/research/`` (the docs' own example) plus SOUL.md."""
mf = DistributionManifest(name=name, version="0.1.0", distribution_owned=["SOUL.md", "skills/research/"])
staged = _make_staging_dir(profile_env, name, manifest=mf)
(staged / "skills" / "research" / "web-search").mkdir(parents=True)
(staged / "skills" / "research" / "web-search" / "SKILL.md").write_text("author skill\n", encoding="utf-8")
(staged / "skills" / "research" / "DESCRIPTION.md").write_text("research skills\n", encoding="utf-8")
(staged / "skills" / "research" / ".DS_Store").write_bytes(b"\0") # stray dotfile a macOS author ships
return staged, install_distribution(str(staged), name=name)
def test_an_owned_category_keeps_skills_the_installer_added_to_it(self, profile_env):
"""``distribution_owned: [skills/research/]`` owns the author's research skills, not the
category: ``hermes skills install`` and agent-created skills land in ``skills/<category>/``
too. The category was replaced wholesale, deleting them (the #25120 loss, still live for
the explicit form the docs show)."""
staged, plan = self._owned_category(profile_env, "rb")
research = plan.target_dir / "skills" / "research"
mine = research / "my-notes"
mine.mkdir()
(mine / "SKILL.md").write_text("my own skill\n", encoding="utf-8")
(research / "web-search" / "stale.txt").write_text("old\n", encoding="utf-8")
(staged / "skills" / "research" / "web-search" / "SKILL.md").write_text("author v2\n", encoding="utf-8")
(staged / "skills" / "research" / "arxiv").mkdir()
(staged / "skills" / "research" / "arxiv" / "SKILL.md").write_text("new author skill\n", encoding="utf-8")
# Category metadata beyond DESCRIPTION.md must not turn the category into a root.
(staged / "skills" / "research" / "README.md").write_text("about research\n", encoding="utf-8")
update_distribution("rb")
assert (mine / "SKILL.md").read_text(encoding="utf-8") == "my own skill\n"
assert (research / "web-search" / "SKILL.md").read_text(encoding="utf-8") == "author v2\n"
assert not (research / "web-search" / "stale.txt").exists() # an owned root is still replaced whole
assert (research / "arxiv" / "SKILL.md").exists()
assert (research / "DESCRIPTION.md").read_text(encoding="utf-8") == "research skills\n"
assert (research / "README.md").read_text(encoding="utf-8") == "about research\n"
# A dir inside a skill is part of that skill: owning ``web-search/scripts`` replaces it whole.
from hermes_cli.profile_distribution import _merges_per_root
scripts = staged / "skills" / "research" / "web-search" / "scripts"
scripts.mkdir()
assert not _merges_per_root(scripts, ("skills", "research", "web-search", "scripts"))
def test_an_owned_category_refuses_a_symlinked_subcategory_before_writing(self, profile_env, tmp_path):
staged, plan = self._owned_category(profile_env, "rb")
(staged / "skills" / "research" / "papers" / "summarize").mkdir(parents=True)
(staged / "skills" / "research" / "papers" / "summarize" / "SKILL.md").write_text("s\n", encoding="utf-8")
outside = tmp_path / "outside"
outside.mkdir()
(plan.target_dir / "skills" / "research" / "papers").symlink_to(outside, target_is_directory=True)
soul = plan.target_dir / "SOUL.md"
before = soul.read_text(encoding="utf-8")
(staged / "SOUL.md").write_text("changed\n", encoding="utf-8")
with pytest.raises(DistributionError, match="symlink"):
update_distribution("rb")
assert not any(outside.iterdir())
assert soul.read_text(encoding="utf-8") == before # refused before the first write
def test_update_merges_cron_jobs_without_losing_local_state(self, profile_env):
"""Updating one shipped definition cannot replace the profile's whole cron store."""
from cron.jobs import create_job, list_jobs, pause_job, resume_job, update_job, use_cron_store
staged = _make_staging_dir(profile_env, "cron_update")
with use_cron_store(staged):
shipped = list_jobs(include_disabled=True)[0]
plan = install_distribution(str(staged), name="cron_merge")
with use_cron_store(plan.target_dir):
resume_job(shipped["id"])
mine = create_job("water the plants", "0 9 * * *", name="mine")
pause_job(mine["id"], reason="my choice")
old_next_run = {
job["id"]: job for job in list_jobs(include_disabled=True)
}[shipped["id"]]["next_run_at"]
with use_cron_store(staged):
update_job(shipped["id"], {
"prompt": "updated upstream definition",
"schedule": "every 7d",
})
update_distribution("cron_merge")
with use_cron_store(plan.target_dir):
jobs = {job["id"]: job for job in list_jobs(include_disabled=True)}
assert mine["id"] in jobs
assert jobs[mine["id"]]["state"] == "paused"
assert jobs[mine["id"]]["paused_reason"] == "my choice"
assert jobs[shipped["id"]]["prompt"] == "updated upstream definition"
assert jobs[shipped["id"]]["schedule"]["minutes"] == 7 * 24 * 60
assert jobs[shipped["id"]]["next_run_at"] != old_next_run
assert jobs[shipped["id"]]["enabled"] is True
# An authored schedule the live job cannot take (past one-shot) is rejected as a
# job-labelled DistributionError before any other file is replaced.
(staged / "SOUL.md").write_text("upstream v3\n")
store = staged / "cron" / "jobs.json"
data = json.loads(store.read_text(encoding="utf-8"))
data["jobs"][0]["schedule"] = {"kind": "once", "run_at": "2020-01-01T00:00:00+00:00", "display": "once"}
store.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(DistributionError, match="cron job 'daily'"):
update_distribution("cron_merge")
assert (plan.target_dir / "SOUL.md").read_text() != "upstream v3\n"
with use_cron_store(plan.target_dir):
assert {j["id"]: j for j in list_jobs(include_disabled=True)}[shipped["id"]]["schedule"] == \
jobs[shipped["id"]]["schedule"]
# A corrupt target store surfaces as DistributionError too, not a raw RuntimeError.
(plan.target_dir / "cron" / "jobs.json").write_text("42", encoding="utf-8")
with pytest.raises(DistributionError, match="Could not merge cron jobs"):
update_distribution("cron_merge")
def test_update_refuses_symlinked_owned_container(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="link_safe")
shared = profile_env / "shared-skills"
(shared / "mine").mkdir(parents=True)
(shared / "mine" / "SKILL.md").write_text("shared skill\n", encoding="utf-8")
before = sorted((p.relative_to(shared), p.read_bytes()) for p in shared.rglob("*") if p.is_file())
# A symlinked category (skills/devops -> shared dir) is a container too and is refused
# rather than unlinked and replaced by the shipped copy.
(staged / "skills" / "devops" / "team-deploy").mkdir(parents=True)
(staged / "skills" / "devops" / "team-deploy" / "SKILL.md").write_text("team deploy\n", encoding="utf-8")
_symlink_file_or_skip(plan.target_dir / "skills" / "devops", shared)
with pytest.raises(DistributionError, match="symlink"):
update_distribution("link_safe")
assert (plan.target_dir / "skills" / "devops").is_symlink()
(plan.target_dir / "skills" / "devops").unlink()
skills = plan.target_dir / "skills"
shutil.rmtree(skills)
_symlink_file_or_skip(skills, shared)
(staged / "skills" / "demo" / "SKILL.md").write_text("updated demo\n", encoding="utf-8")
# Every other shipped entry changes upstream too: the refusal must fire before the
# first write, or the profile is left half-updated and every retry fails the same way.
(staged / "SOUL.md").write_text("updated soul\n", encoding="utf-8")
(staged / "mcp.json").write_text('{"servers": {"new": {}}}\n', encoding="utf-8")
(staged / "cron" / "daily.json").write_text('{"schedule": "0 10 * * *"}', encoding="utf-8")
untouched = {p: p.read_bytes() for p in plan.target_dir.rglob("*") if p.is_file()}
with pytest.raises(DistributionError, match="symlink"):
update_distribution("link_safe")
assert skills.is_symlink() and skills.resolve() == shared.resolve()
after = sorted((p.relative_to(shared), p.read_bytes()) for p in shared.rglob("*") if p.is_file())
assert after == before
assert {p: p.read_bytes() for p in plan.target_dir.rglob("*") if p.is_file()} == untouched
def test_update_preserves_user_data(self, profile_env):
# 1. Build staging dir, install
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="telem")
# 2. Add user-owned data to the installed profile
(plan.target_dir / "memories").mkdir(exist_ok=True)
(plan.target_dir / "memories" / "MEMORY.md").write_text("# USER MEMORY\n")
(plan.target_dir / ".env").write_text("OPENAI_API_KEY=sk-user\n")
(plan.target_dir / "auth.json").write_text('{"user": "auth"}')
(plan.target_dir / "sessions").mkdir(exist_ok=True)
(plan.target_dir / "sessions" / "chat.json").write_text('{"s": 1}')
# 3. Bump source in the staging dir
(staged / "SOUL.md").write_text("I am Source v2.\n")
# 4. Update
update_distribution("telem", force_config=False)
# 5. Dist-owned changed
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source v2.\n"
# 6. User-owned preserved
assert (plan.target_dir / "memories" / "MEMORY.md").read_text() == "# USER MEMORY\n"
assert (plan.target_dir / ".env").read_text() == "OPENAI_API_KEY=sk-user\n"
assert (plan.target_dir / "auth.json").read_text() == '{"user": "auth"}'
assert (plan.target_dir / "sessions" / "chat.json").read_text() == '{"s": 1}'
def test_update_preserves_config_by_default(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="t2")
# User edits config
(plan.target_dir / "config.yaml").write_text(
"model:\n model: gpt-5\n# user override\n"
)
# Bump source config
(staged / "config.yaml").write_text("model:\n model: claude\n")
update_distribution("t2", force_config=False)
assert "gpt-5" in (plan.target_dir / "config.yaml").read_text()
assert "user override" in (plan.target_dir / "config.yaml").read_text()
def test_update_missing_manifest_errors(self, profile_env):
# Make a profile without a manifest; update must refuse
from hermes_cli.profiles import create_profile
create_profile(name="plain", no_alias=True)
with pytest.raises(DistributionError, match="not a distribution"):
update_distribution("plain")
# ===========================================================================
# describe_distribution — info subcommand
# ===========================================================================
class TestDescribe:
def test_describe_existing_distribution(self, profile_env):
mf = DistributionManifest(
name="telem",
version="1.0.0",
description="compliance monitor",
env_requires=[EnvRequirement(name="API", description="api key")],
)
staged = _make_staging_dir(profile_env, "telem", manifest=mf)
install_distribution(str(staged), name="telem")
data = describe_distribution("telem")
assert data["name"] == "telem"
assert data["version"] == "1.0.0"
assert data["env_requires"][0]["name"] == "API"
def test_describe_missing_profile_raises(self, profile_env):
with pytest.raises(DistributionError, match="No profile named .*hermes profile list"):
describe_distribution("nonexistent")
# ===========================================================================
# Security — USER_OWNED_EXCLUDE covers the right paths
# ===========================================================================
class TestSecurity:
def test_install_does_not_import_credentials_from_staging(self, profile_env):
"""If an author accidentally ships auth.json or .env in their
staging dir, the installer must NOT copy them to the target profile."""
staged = _make_staging_dir(profile_env, "src")
# Author leaks credentials into the staging tree (shouldn't happen, but...)
(staged / "auth.json").write_text('{"leaked": true}')
(staged / ".env").write_text("LEAKED=1")
plan = install_distribution(str(staged), name="clean")
assert not (plan.target_dir / "auth.json").exists(), "auth.json leaked"
# Fresh profile may have its own .env via the bootstrap; what we care
# about is that the leaked content didn't land in the target.
if (plan.target_dir / ".env").exists():
assert "LEAKED" not in (plan.target_dir / ".env").read_text()
def test_install_rejects_symlinked_distribution_files(self, profile_env, tmp_path):
"""Distribution install must not follow symlinks to local files."""
staged = _make_staging_dir(profile_env, "src")
local_secret = tmp_path / "local-secret.txt"
local_secret.write_text("outside secret\n")
_symlink_file_or_skip(
staged / "skills" / "demo" / "leak.txt",
local_secret,
)
with pytest.raises(DistributionError, match="symlink"):
install_distribution(str(staged), name="clean")
from hermes_cli.profiles import get_profile_dir
target = get_profile_dir("clean")
assert not (target / "skills" / "demo" / "leak.txt").exists()
# ===========================================================================
# Nested directories whose names match USER_OWNED_EXCLUDE must survive install
# ===========================================================================
class TestNestedUserOwnedExcludeNotFiltered:
def test_nested_bin_dir_is_preserved(self, profile_env):
"""A distribution shipping assets/bin/ must not have assets/bin/ dropped
during install even though 'bin' is in USER_OWNED_EXCLUDE."""
mf = DistributionManifest(
name="nested_bin",
version="0.1.0",
distribution_owned=list(DEFAULT_DIST_OWNED) + ["assets"],
)
staged = _make_staging_dir(profile_env, "src", manifest=mf)
(staged / "assets" / "bin").mkdir(parents=True)
(staged / "assets" / "bin" / "tool.py").write_text("# tool\n")
plan = install_distribution(str(staged), name="nested_bin")
assert (plan.target_dir / "assets" / "bin").is_dir(), "nested bin/ was dropped"
assert (plan.target_dir / "assets" / "bin" / "tool.py").exists()
def test_top_level_user_owned_still_skipped(self, profile_env):
"""Top-level entries in USER_OWNED_EXCLUDE must still be skipped —
only nested (deeper) directories should be preserved.
Note: _bootstrap_user_dirs creates some of these (logs/, sessions/,
memories/) in every fresh profile, so we check that the *staged content*
did not leak through rather than asserting the directory doesn't exist."""
staged = _make_staging_dir(profile_env, "src")
# Add top-level excluded entries alongside the legit ones
(staged / "bin").mkdir(exist_ok=True)
(staged / "bin" / "shipped_binary").write_text("x")
(staged / "logs").mkdir(exist_ok=True)
(staged / "logs" / "shipped.log").write_text("y\n")
plan = install_distribution(str(staged), name="top_filter")
# bin/ is not created by _bootstrap_user_dirs so absence means filtered
assert not (plan.target_dir / "bin").exists(), "top-level bin/ should be filtered"
# logs/ is created by _bootstrap_user_dirs even on a clean profile,
# so check that the staged file did NOT land there.
assert not (plan.target_dir / "logs" / "shipped.log").exists(), \
"staged logs/ content should not leak into target"
def test_both_nested_and_top_level_coexist(self, profile_env):
"""Top-level bin/ filtered, but assets/bin/ kept."""
mf = DistributionManifest(
name="coexist",
version="0.1.0",
distribution_owned=list(DEFAULT_DIST_OWNED) + ["assets"],
)
staged = _make_staging_dir(profile_env, "src", manifest=mf)
(staged / "bin").mkdir(exist_ok=True)
(staged / "bin" / "top.sh").write_text("# top\n")
(staged / "assets" / "bin").mkdir(parents=True)
(staged / "assets" / "bin" / "helper.py").write_text("# helper\n")
plan = install_distribution(str(staged), name="coexist")
assert not (plan.target_dir / "bin").exists()
assert (plan.target_dir / "assets" / "bin" / "helper.py").exists()
# ===========================================================================
# Install-time metadata (installed_at stamp)
# ===========================================================================
class TestInstalledAtStamp:
def test_install_stamps_installed_at(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="stamped")
mf = read_manifest(plan.target_dir)
assert mf.installed_at, "installed_at should be set after install"
# ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'.
assert mf.installed_at[:4].isdigit()
assert "T" in mf.installed_at
assert mf.installed_at.endswith("+00:00")
def test_update_refreshes_installed_at(self, profile_env, monkeypatch):
staged = _make_staging_dir(profile_env, "src")
install_distribution(str(staged), name="demo")
from hermes_cli.profiles import get_profile_dir
first = read_manifest(get_profile_dir("demo")).installed_at
# Freeze `datetime.now()` to a fixed future time so we can observe that
# update writes a NEW stamp (installs within the same second otherwise
# collide at iso-8601 seconds resolution).
import datetime as _dt
class _FakeDT(_dt.datetime):
@classmethod
def now(cls, tz=None):
return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc)
monkeypatch.setattr(
"hermes_cli.profile_distribution.datetime", _FakeDT, raising=True
)
from hermes_cli.profile_distribution import update_distribution
update_distribution("demo")
refreshed = read_manifest(get_profile_dir("demo")).installed_at
assert refreshed != first, "installed_at should change on update"
assert refreshed.startswith("2099-01-01"), refreshed
# ===========================================================================
# ProfileInfo exposes distribution metadata
# ===========================================================================
class TestProfileInfoDistribution:
def test_installed_distribution_shows_in_list(self, profile_env):
staged = _make_staging_dir(
profile_env, "src",
manifest=DistributionManifest(name="telem", version="1.2.3"),
)
install_distribution(str(staged), name="telem")
from hermes_cli.profiles import list_profiles
rows = {p.name: p for p in list_profiles()}
assert "telem" in rows
row = rows["telem"]
assert row.distribution_name == "telem"
assert row.distribution_version == "1.2.3"
assert row.distribution_source # path populated, exact value depends on fixture
def test_malformed_manifest_does_not_break_list(self, profile_env):
from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir
create_profile(name="brokenmeta", no_alias=True)
# Write a distribution.yaml that isn't a valid mapping
(get_profile_dir("brokenmeta") / "distribution.yaml").write_text(
"not: [a, valid, mapping\n" # broken YAML
)
# list_profiles must NOT raise; distribution_* stay None for this row.
rows = {p.name: p for p in list_profiles()}
assert rows["brokenmeta"].distribution_name is None
# ===========================================================================
# Error surfaces: validation failures should propagate as DistributionError
# or ValueError (both caught and rendered cleanly by the CLI handler)
# ===========================================================================
class TestErrorSurfaces:
def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path):
"""A manifest whose 'name' can't be used as a profile identifier
should raise ValueError from validate_profile_name — the CLI handler
catches both DistributionError and ValueError so users see a clean
'Error: ...' line instead of a Python traceback.
"""
mf = DistributionManifest(name="Invalid Name With Spaces", version="0.1.0")
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
with pytest.raises((ValueError, DistributionError)):
plan_install(str(staged), tmp_path / "work")
# ===========================================================================
# Crash durability: write_manifest rewrites distribution.yaml in place
# ===========================================================================
class TestManifestCrashDurability:
"""``write_manifest`` runs on every install and update of a shared profile.
``read_manifest`` reports a missing-or-unparseable manifest as "this isn't
a distribution", so a truncated distribution.yaml silently demotes the
profile — update tracking and ``env_requires`` just stop existing, with no
error surfaced anywhere.
"""
def test_previous_manifest_survives_an_interrupted_write(self, tmp_path):
import os
original = DistributionManifest(
name="keepme",
version="1.0.0",
description="the manifest already on disk",
env_requires=[EnvRequirement(name="FOO", description="foo")],
)
write_manifest(tmp_path, original)
on_disk = (tmp_path / "distribution.yaml").read_bytes()
def boom(fd):
raise OSError("simulated crash mid-write")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(os, "fsync", boom)
with pytest.raises(OSError):
write_manifest(
tmp_path,
DistributionManifest(name="replacement", version="2.0.0"),
)
# The old manifest must still be byte-identical and still parse.
assert (tmp_path / "distribution.yaml").read_bytes() == on_disk
parsed = read_manifest(tmp_path)
assert parsed is not None, "profile silently stopped being a distribution"
assert parsed.name == "keepme"
assert parsed.env_requires[0].name == "FOO"
# No temp file left behind next to the manifest.
assert list(tmp_path.glob("*.tmp")) == []
@pytest.mark.platforms("posix") # POSIX permission bits
def test_existing_file_mode_is_preserved(self, tmp_path):
import os
import stat
write_manifest(tmp_path, DistributionManifest(name="modes", version="1.0.0"))
mf = tmp_path / "distribution.yaml"
os.chmod(mf, 0o644)
write_manifest(tmp_path, DistributionManifest(name="modes", version="2.0.0"))
mode = stat.S_IMODE(mf.stat().st_mode)
assert mode == 0o644, f"mode changed to {oct(mode)}"
@pytest.mark.platforms("posix") # POSIX permission bits
def test_created_file_mode_is_not_tightened(self, tmp_path):
"""A manifest this function *creates* must not land owner-only.
``atomic_yaml_write`` only re-applies a mode it captured from an
existing file, so a fresh distribution.yaml would otherwise keep
``tempfile.mkstemp``'s 0600. ``_materialize`` hits that path whenever a
distribution's explicit ``distribution_owned`` allowlist omits
distribution.yaml, so the staged copy never lands in the profile.
"""
import stat
mf = tmp_path / "distribution.yaml"
assert not mf.exists()
write_manifest(tmp_path, DistributionManifest(name="fresh", version="1.0.0"))
mode = stat.S_IMODE(mf.stat().st_mode)
assert mode == 0o644, f"new manifest created as {oct(mode)}"