Files
hermes-agent/tests/tools/test_skill_provenance_external.py
Brooklyn Nicholson 10990b5a00 test(learning-graph): force the external-mount-first scan order
test_used_local_skill_still_produces_learning_node was reported as failing
under scripts/run_tests.sh (env -i) but passing under bare pytest, which read
as ambient-env leakage. Reproduction on both the reviewed head and the current
one shows the suite green under the scrubbed runner; the flake the reviewer hit
was the 'external' source loop-variable leak (fixed in 71485f5763), whose
manifestation depends on rglob scan order — env -i changes TMPDIR, which
changes the tmpdir layout, which changes the order the mount and the local
skill are yielded in. That env-dependence was coincidental, not causal.

Pin the hostile order directly: patch Path.rglob so the external mount's
SKILL.md always scans before the local skill, and assert the local skill still
classifies 'agent' (red at the reviewed head, green now). Also drop the
no-op monkeypatch of hermes_constants._hermes_home_cache — no such attribute
exists, so it reset nothing and only implied a cache reset that never happened.
2026-09-30 23:10:24 -05:00

150 lines
6.9 KiB
Python

"""#108032: external-dir skills get an 'external' provenance tier, not 'agent'.
Externally mounted skills (skills.external_dirs) are externally authored: the label must say
where they came from without changing their foreground mutability (in-place edits are the
standing design, commit 8c8fc6c1ec / PR #17512) and without counting as learning milestones
in the journey graph.
"""
import pytest
@pytest.fixture
def external_home(tmp_path, monkeypatch):
"""HERMES_HOME with one external dir configured and one external skill in it.
The external skill is ALSO reachable from the profile skills tree via a symlinked
SKILL.md — the common mounting style — because that is the shape the journey graph
actually sees (its roots are the bundled and profile skills dirs; rglob there does not
traverse dir symlinks but does yield symlinked files, and is_external_skill_path
resolves the link to tag it 'external').
"""
home = tmp_path / ".hermes"
(home / "skills").mkdir(parents=True)
ext_dir = tmp_path / "vault"
(ext_dir / "ext-skill").mkdir(parents=True)
(ext_dir / "ext-skill" / "SKILL.md").write_text(
"---\nname: ext-skill\ndescription: An external skill.\n---\n\n# External\n", encoding="utf-8")
(home / "skills" / "ext-linked").mkdir()
(home / "skills" / "ext-linked" / "SKILL.md").symlink_to(ext_dir / "ext-skill" / "SKILL.md")
(home / "config.yaml").write_text(f"skills:\n external_dirs:\n - {ext_dir}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
from agent import skill_utils
skill_utils._external_dirs_cache_clear()
yield home
skill_utils._external_dirs_cache_clear()
def _write_local_skill(home, name: str):
d = home / "skills" / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Local skill.\n---\n\n# Local\n", encoding="utf-8")
class TestProvenanceTier:
def test_external_only_skill_classifies_as_external(self, external_home):
from tools.skill_usage import provenance
assert provenance("ext-skill") == "external"
def test_local_skill_stays_agent(self, external_home):
_write_local_skill(external_home, "my-local")
from tools.skill_usage import provenance
assert provenance("my-local") == "agent"
def test_local_copy_wins_over_external_mount(self, external_home):
"""A name present both locally and externally is NOT external (local store wins)."""
_write_local_skill(external_home, "ext-skill")
from tools.skill_usage import provenance
assert provenance("ext-skill") == "agent"
def test_agent_created_still_excludes_external_only(self, external_home):
from tools.skill_usage import is_agent_created
assert is_agent_created("ext-skill") is False
def test_external_names_helper_scans_external_dirs(self, external_home):
from tools.skill_usage import _external_skill_names
assert _external_skill_names() == {"ext-skill"}
class TestLearningGraphExcludesExternal:
def test_used_external_skill_produces_no_learning_node(self, external_home):
"""Even a USED external skill is not a learning milestone (#108032)."""
from agent import learning_graph
with _patched_usage({"ext-skill": {"use_count": 5, "created_by": None}}):
skill_ids = {n["id"] for n in learning_graph.build_learning_graph()["nodes"]
if n["kind"] == "skill"}
assert "ext-skill" not in skill_ids
def test_used_local_skill_still_produces_learning_node(self, external_home):
_write_local_skill(external_home, "my-local")
from agent import learning_graph
with _patched_usage({"my-local": {"use_count": 3, "created_by": None}}):
skill_ids = {n["id"] for n in learning_graph.build_learning_graph()["nodes"]
if n["kind"] == "skill"}
assert "my-local" in skill_ids
def test_external_source_does_not_leak_to_later_skills_in_a_root(self, external_home, monkeypatch):
"""Order-forced twin of the above: when the external mount scans BEFORE the local
skill in one rglob root, the local skill must still classify 'agent' — the loop
must not carry 'external' forward on the ``source`` variable. The original leak only
surfaced under filesystem layouts that happened to interleave the mount first, which
is why it masqueraded as a runner/env-dependence (run_tests.sh vs bare pytest) rather
than the scan-order bug it was."""
from pathlib import Path
from agent import learning_graph
_write_local_skill(external_home, "my-local")
real_rglob = Path.rglob
def hostile_rglob(self, pattern):
hits = sorted(real_rglob(self, pattern))
# Every skill root: yield the external mount's SKILL.md BEFORE any local one.
return sorted(hits, key=lambda p: 0 if "ext-" in p.parent.name else 1)
monkeypatch.setattr(Path, "rglob", hostile_rglob)
with _patched_usage({"my-local": {"use_count": 3, "created_by": None}}):
graph = learning_graph.build_learning_graph()
sources = {n["id"]: n.get("source") for n in graph["nodes"] if n["kind"] == "skill"}
assert "my-local" in sources and sources["my-local"] != "external"
def _patched_usage(data):
from unittest.mock import patch
from agent import learning_graph
return patch.object(learning_graph, "_load_usage", lambda: data)
class TestWebRouterProvenanceTwin:
"""The /api/skills set-based twin must classify external mounts as 'external' while a
local hand-made skill stays 'agent' (the regression this guards: hub > bundled > agent
with no external tier flattened mounts into 'agent').
``get_skills`` resolves its imports inside the function body, so monkeypatching router
module attributes cannot intercept them; drive the real app through TestClient instead
(same harness as test_web_server_skills_profiles.py)."""
@pytest.fixture
def client(self, external_home, monkeypatch):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def test_router_labels_external_vs_agent(self, client, external_home):
_write_local_skill(external_home, "my-local")
resp = client.get("/api/skills")
assert resp.status_code == 200, resp.text
by_name = {s["name"]: s["provenance"] for s in resp.json()}
assert by_name.get("ext-skill") == "external"
assert by_name.get("my-local") == "agent"