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.
150 lines
6.9 KiB
Python
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"
|