Files
hive/core/tests/test_skill_overrides.py
2026-04-22 21:33:33 -07:00

275 lines
11 KiB
Python

"""Tests for the per-scope skill override store and its interaction with SkillsManager."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from framework.skills.authoring import build_draft, write_skill
from framework.skills.config import SkillsConfig
from framework.skills.discovery import ExtraScope
from framework.skills.manager import SkillsManager, SkillsManagerConfig
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
)
def _write_skill_file(base: Path, name: str, description: str = "desc") -> Path:
skill_dir = base / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\nbody\n",
encoding="utf-8",
)
return skill_dir
class TestSkillOverrideStore:
def test_load_missing_returns_empty(self, tmp_path: Path) -> None:
store = SkillOverrideStore.load(tmp_path / "skills_overrides.json", scope_label="queen:x")
assert store.overrides == {}
assert store.all_defaults_disabled is False
def test_upsert_and_save_roundtrip(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
store = SkillOverrideStore.load(path, scope_label="queen:x")
store.upsert(
"foo",
OverrideEntry(
enabled=False,
provenance=Provenance.FRAMEWORK,
created_at=datetime(2026, 4, 21, tzinfo=UTC),
created_by="user",
),
)
store.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["version"] == 1
assert raw["overrides"]["foo"]["enabled"] is False
assert raw["overrides"]["foo"]["provenance"] == "framework"
# Re-load preserves values
again = SkillOverrideStore.load(path, scope_label="queen:x")
assert again.get("foo") is not None
assert again.get("foo").enabled is False
def test_tombstone_survives_reload(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
store = SkillOverrideStore.load(path, scope_label="queen:x")
store.upsert("foo", OverrideEntry(enabled=True, provenance=Provenance.USER_UI_CREATED))
store.remove("foo", tombstone=True)
store.save()
again = SkillOverrideStore.load(path, scope_label="queen:x")
assert "foo" in again.deleted_ui_skills
assert again.get("foo") is None
def test_corrupt_file_loads_empty(self, tmp_path: Path) -> None:
path = tmp_path / "skills_overrides.json"
path.write_text("{not valid json", encoding="utf-8")
store = SkillOverrideStore.load(path, scope_label="queen:x")
assert store.overrides == {}
class TestAuthoring:
def test_write_and_remove(self, tmp_path: Path) -> None:
draft, err = build_draft(
skill_name="demo",
skill_description="A demo skill",
skill_body="## Steps\n1. Do it.\n",
skill_files=[{"path": "notes.md", "content": "notes"}],
)
assert err is None
assert draft is not None
installed, werr, replaced = write_skill(draft, target_root=tmp_path, replace_existing=True)
assert werr is None
assert installed is not None
assert (installed / "SKILL.md").exists()
assert (installed / "notes.md").read_text() == "notes"
assert replaced is False
def test_reject_absolute_path(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="demo",
skill_description="desc",
skill_body="body",
skill_files=[{"path": "/etc/passwd", "content": "oops"}],
)
assert err is not None
assert "relative" in err
def test_reject_traversal(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="demo",
skill_description="desc",
skill_body="body",
skill_files=[{"path": "../escape.sh", "content": "oops"}],
)
assert err is not None
def test_reject_invalid_name(self, tmp_path: Path) -> None:
_, err = build_draft(
skill_name="Demo_Skill",
skill_description="desc",
skill_body="body",
)
assert err is not None
class TestSkillsManagerOverrides:
def test_override_disables_framework_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Quarantine user-scope and skip framework-scope discovery by pointing HOME
# at an empty tmp dir; supply only one "framework" skill manually via an
# extra scope tagged as framework so the manager sees it.
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fake_fw = tmp_path / "fake_framework"
_write_skill_file(fake_fw, "hive.note-taking", "Fake default")
overrides_path = tmp_path / "queen_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label="queen:q")
store.upsert(
"hive.note-taking",
OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK),
)
store.save()
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=overrides_path,
extra_scope_dirs=[ExtraScope(directory=fake_fw, label="framework", priority=0)],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
names_enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "hive.note-taking" not in names_enabled
# Enumeration (for UI rendering) still returns the hidden entry.
assert any(s.name == "hive.note-taking" for s in mgr.enumerate_skills_with_source())
def test_colony_disable_overrides_queen_enable(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
# One skill in a "queen_ui" extra scope.
queen_skills = tmp_path / "queen_home" / "skills"
_write_skill_file(queen_skills, "shared-skill")
queen_overrides = tmp_path / "queen_overrides.json"
qstore = SkillOverrideStore.load(queen_overrides, scope_label="queen:q")
qstore.upsert(
"shared-skill",
OverrideEntry(enabled=True, provenance=Provenance.USER_UI_CREATED),
)
qstore.save()
colony_overrides = tmp_path / "colony_overrides.json"
cstore = SkillOverrideStore.load(colony_overrides, scope_label="colony:c")
cstore.upsert(
"shared-skill",
OverrideEntry(enabled=False, provenance=Provenance.USER_UI_CREATED),
)
cstore.save()
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=queen_overrides,
colony_name="c",
colony_overrides_path=colony_overrides,
extra_scope_dirs=[ExtraScope(directory=queen_skills, label="queen_ui", priority=2)],
project_root=None,
skip_community_discovery=True,
skills_config=SkillsConfig(),
interactive=False,
)
)
mgr.load()
enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "shared-skill" not in enabled
def test_preset_scope_is_off_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Preset-scope skills (bundled capability packs) must stay out
of the catalog until the user explicitly opts in."""
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fake_presets = tmp_path / "fake_presets"
_write_skill_file(fake_presets, "hive.x-automation", "X capability pack")
_write_skill_file(fake_presets, "hive.browser-automation", "Browser pack")
mgr = SkillsManager(
SkillsManagerConfig(
extra_scope_dirs=[ExtraScope(directory=fake_presets, label="preset", priority=1)],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "hive.x-automation" not in enabled
assert "hive.browser-automation" not in enabled
# Enumeration still surfaces them so the UI can offer a toggle.
enumerated = {s.name for s in mgr.enumerate_skills_with_source()}
assert "hive.x-automation" in enumerated
assert "hive.browser-automation" in enumerated
def test_preset_skill_enabled_via_explicit_override(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fake_presets = tmp_path / "fake_presets"
_write_skill_file(fake_presets, "hive.x-automation")
overrides_path = tmp_path / "queen_overrides.json"
store = SkillOverrideStore.load(overrides_path, scope_label="queen:q")
store.upsert(
"hive.x-automation",
OverrideEntry(enabled=True, provenance=Provenance.PRESET),
)
store.save()
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=overrides_path,
extra_scope_dirs=[ExtraScope(directory=fake_presets, label="preset", priority=1)],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
enabled = {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
assert "hive.x-automation" in enabled
def test_reload_picks_up_store_change(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
fw = tmp_path / "fw"
_write_skill_file(fw, "alpha")
path = tmp_path / "queen.json"
mgr = SkillsManager(
SkillsManagerConfig(
queen_id="q",
queen_overrides_path=path,
extra_scope_dirs=[ExtraScope(directory=fw, label="framework", priority=0)],
project_root=None,
skip_community_discovery=True,
interactive=False,
)
)
mgr.load()
assert "alpha" in {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]
# Disable via override file + reload
store = SkillOverrideStore.load(path, scope_label="queen:q")
store.upsert("alpha", OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK))
store.save()
mgr.reload()
assert "alpha" not in {s.name for s in mgr._catalog._skills.values()} # type: ignore[attr-defined]