Files
microsoft--skillopt/tests/test_sleep_skill_resolver.py
Bogdan (Dan) Baciu faf4700ae2 feat(sleep): adopt reviewed skill subsets safely (#212)
* feat(sleep): adopt reviewed skill subsets safely

* fix(sleep): wire cycle staging and adopt-time review checks

Address PR 212 review: run_sleep_cycle stages resolved SkillProposals,
status/adopt list and select a subset, uniqueness is rechecked at adopt,
and a failed adopted_skills.json write rolls live files back.

Refs microsoft/SkillOpt#212

* test(sleep): mega-cover PR 212 review paths

Adversarial CLI, adopt-time, cycle-staging, and auto-adopt cases for
Yifan's five review items. Also tidy isort on the files this slice
touches.

Refs microsoft/SkillOpt#120

* fix(sleep): pin staged skill hashes and confine adopt targets

Harden PR 212 adopt: sha256 pin each staged skill, revalidate the
whole manifest before any live write, refuse symlink/missing-parent
targets, skip notes on the cycle report, and reject empty --skill.

Refs microsoft/SkillOpt#212

* fix(sleep): harden multi-skill fan-out adoption end to end

---------

Co-authored-by: Yif-Yang <yif_yang@qq.com>
2026-08-21 04:34:29 +08:00

377 lines
18 KiB
Python

"""Tests for bounded skill-name resolution (issue #120).
Pure-stdlib (unittest), hermetic (tmpdir only), no API key, no network.
Run: python -m pytest tests/test_sleep_skill_resolver.py
"""
from __future__ import annotations
import os
import tempfile
import unittest
from skillopt_sleep.config import load_config
from skillopt_sleep.skill_resolver import (
AMBIGUOUS,
FOUND,
MISSING,
REJECTED,
normalize_skill_name,
resolve_skill,
skill_search_roots,
)
def _write_skill(root, name, body="# skill\n"):
path = os.path.join(root, name, "SKILL.md")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(body)
return path
def _symlink(test, source, link_name):
"""Create a symlink, or skip the test where the platform refuses one.
Windows needs admin or Developer Mode for symlinks, and some CI sandboxes
disallow them outright. The behaviour under test is a security property
that only exists where symlinks do, so skipping is correct rather than
failing on an unrelated platform limitation.
"""
try:
os.symlink(source, link_name)
except (OSError, NotImplementedError, AttributeError) as exc:
test.skipTest(f"symlinks unavailable on this platform: {exc}")
class TestNormalizeSkillName(unittest.TestCase):
def test_trims_but_preserves_case_and_punctuation(self):
self.assertEqual(normalize_skill_name(" Brand-Voice.v2 "), "Brand-Voice.v2")
def test_rejects_unusable_names(self):
for bad in ["", " ", ".", "..", "../escape", "a/b", "a\\b", "/abs/skill",
"~/skill", "bad\nname", "bad\x00name", None, 3]:
self.assertEqual(normalize_skill_name(bad), "", repr(bad))
class TestResolveSkill(unittest.TestCase):
def test_resolves_a_single_local_skill(self):
with tempfile.TemporaryDirectory() as tmp:
expected = _write_skill(tmp, "example-skill")
res = resolve_skill(" example-skill ", [tmp])
self.assertEqual(res.status, FOUND)
self.assertTrue(res.ok)
self.assertEqual(res.path, os.path.realpath(expected))
self.assertEqual(res.name, "example-skill")
def test_missing_skill_is_distinct_from_ambiguous(self):
with tempfile.TemporaryDirectory() as tmp:
_write_skill(tmp, "other-skill")
res = resolve_skill("example-skill", [tmp])
self.assertEqual(res.status, MISSING)
self.assertEqual(res.path, "")
self.assertEqual(res.candidates, ())
def test_directory_without_skill_file_is_missing(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(os.path.join(tmp, "example-skill"))
self.assertEqual(resolve_skill("example-skill", [tmp]).status, MISSING)
def test_same_skill_in_two_roots_is_ambiguous(self):
with tempfile.TemporaryDirectory() as tmp:
local, cache = os.path.join(tmp, "local"), os.path.join(tmp, "cache")
first = _write_skill(local, "example-skill")
second = _write_skill(cache, "example-skill")
res = resolve_skill("example-skill", [local, cache])
self.assertEqual(res.status, AMBIGUOUS)
self.assertEqual(res.path, "")
self.assertEqual(res.candidates,
(os.path.realpath(first), os.path.realpath(second)))
def test_repeated_root_is_not_ambiguous(self):
with tempfile.TemporaryDirectory() as tmp:
_write_skill(tmp, "example-skill")
self.assertEqual(resolve_skill("example-skill", [tmp, tmp]).status, FOUND)
def test_traversal_is_rejected_without_touching_the_filesystem(self):
with tempfile.TemporaryDirectory() as tmp:
root = os.path.join(tmp, "roots")
_write_skill(tmp, "outside-skill")
os.makedirs(root, exist_ok=True)
res = resolve_skill("../outside-skill", [root])
self.assertEqual(res.status, REJECTED)
self.assertEqual(res.path, "")
def test_symlinked_skill_dir_escaping_the_root_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
root = os.path.join(tmp, "roots")
os.makedirs(root)
outside = os.path.join(tmp, "outside")
_write_skill(outside, "example-skill")
_symlink(self, os.path.join(outside, "example-skill"),
os.path.join(root, "example-skill"))
self.assertEqual(resolve_skill("example-skill", [root]).status, MISSING)
def test_symlinked_skill_file_escaping_the_root_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
root = os.path.join(tmp, "roots")
os.makedirs(os.path.join(root, "example-skill"))
elsewhere = os.path.join(tmp, "elsewhere.md")
with open(elsewhere, "w", encoding="utf-8") as f:
f.write("# not in the root\n")
_symlink(self, elsewhere, os.path.join(root, "example-skill", "SKILL.md"))
self.assertEqual(resolve_skill("example-skill", [root]).status, MISSING)
def test_symlinked_root_itself_still_resolves(self):
with tempfile.TemporaryDirectory() as tmp:
real = os.path.join(tmp, "real")
_write_skill(real, "example-skill")
link = os.path.join(tmp, "link")
_symlink(self, real, link)
self.assertEqual(resolve_skill("example-skill", [link]).status, FOUND)
def test_resolution_never_modifies_the_skill(self):
with tempfile.TemporaryDirectory() as tmp:
path = _write_skill(tmp, "example-skill", "# original\n")
with open(path, encoding="utf-8") as f:
before = (os.stat(path).st_size, f.read())
resolve_skill("example-skill", [tmp])
with open(path, encoding="utf-8") as f:
after = (os.stat(path).st_size, f.read())
self.assertEqual(after, before)
def test_no_roots_is_missing(self):
self.assertEqual(resolve_skill("example-skill", []).status, MISSING)
def test_candidates_are_an_immutable_tuple(self):
with tempfile.TemporaryDirectory() as tmp:
_write_skill(tmp, "example-skill")
res = resolve_skill("example-skill", [tmp])
self.assertIsInstance(res.candidates, tuple)
with self.assertRaises(AttributeError):
res.candidates.append("/injected") # type: ignore[attr-defined]
def test_skill_file_on_another_drive_is_refused_not_crashed(self):
# os.path.commonpath raises ValueError for paths that share no root
# (mixed drives on Windows). Resolution must treat that as "outside".
with tempfile.TemporaryDirectory() as tmp:
_write_skill(tmp, "example-skill")
real_commonpath = os.path.commonpath
def exploding_commonpath(paths):
raise ValueError("paths don't have the same drive")
os.path.commonpath = exploding_commonpath
try:
self.assertEqual(resolve_skill("example-skill", [tmp]).status, MISSING)
finally:
os.path.commonpath = real_commonpath
class TestSkillSearchRoots(unittest.TestCase):
def test_user_skills_root_comes_first_then_plugin_cache(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
skills = os.path.join(claude_home, "skills")
os.makedirs(skills)
plugin_skills = os.path.join(
claude_home, "plugins", "cache", "marketplace", "plugin", "skills"
)
os.makedirs(plugin_skills)
cfg = load_config(claude_home=claude_home)
self.assertEqual(skill_search_roots(cfg), [skills, plugin_skills])
def test_absent_roots_are_skipped(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = load_config(claude_home=os.path.join(tmp, ".claude"))
self.assertEqual(skill_search_roots(cfg), [])
def test_blank_claude_home_never_falls_back_to_the_cwd(self):
# os.path.abspath("") is the CWD; a blank override must not turn the
# working directory into a skill root.
class _Cfg:
def __init__(self, claude_home):
self.claude_home = claude_home
for blank in ["", " ", None]:
self.assertEqual(skill_search_roots(_Cfg(blank)), [], repr(blank))
def test_project_native_agent_roots_and_explicit_roots_are_discovered(self):
with tempfile.TemporaryDirectory() as tmp:
expected = []
for relative in (
os.path.join(".agents", "skills"),
os.path.join(".claude", "skills"),
os.path.join(".cursor", "skills"),
os.path.join(".devin", "skills"),
"custom-skills",
):
root = os.path.join(tmp, relative)
os.makedirs(root)
expected.append(os.path.realpath(root))
cfg = load_config(
invoked_project=tmp,
claude_home="",
codex_home="",
cursor_home="",
skill_roots=["custom-skills"],
)
self.assertEqual(skill_search_roots(cfg), expected)
def test_native_roots_make_duplicate_agent_definitions_ambiguous(self):
with tempfile.TemporaryDirectory() as tmp:
for relative in (".agents/skills", ".devin/skills"):
_write_skill(os.path.join(tmp, relative), "shared-skill")
cfg = load_config(
invoked_project=tmp,
claude_home="",
codex_home="",
cursor_home="",
)
result = resolve_skill("shared-skill", skill_search_roots(cfg))
self.assertEqual(result.status, AMBIGUOUS)
self.assertEqual(len(result.candidates), 2)
def test_unreadable_plugin_cache_does_not_break_discovery(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
skills = os.path.join(claude_home, "skills")
os.makedirs(skills)
cache = os.path.join(claude_home, "plugins", "cache")
os.makedirs(cache)
try:
os.chmod(cache, 0o000)
except OSError as exc: # pragma: no cover - platform dependent
self.skipTest(f"cannot drop permissions on this platform: {exc}")
if os.access(cache, os.R_OK):
# Windows and some filesystems ignore mode bits, and root
# bypasses them, so the unreadable precondition never holds.
os.chmod(cache, 0o700)
self.skipTest("directory is still readable after chmod 000")
try:
cfg = load_config(claude_home=claude_home)
self.assertEqual(skill_search_roots(cfg), [skills])
finally:
try:
os.chmod(cache, 0o700)
except OSError: # pragma: no cover - best-effort restore
pass
def test_resolution_through_config_roots_prefers_the_user_skill(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
expected = _write_skill(os.path.join(claude_home, "skills"), "example-skill")
cfg = load_config(claude_home=claude_home)
res = resolve_skill("example-skill", skill_search_roots(cfg))
self.assertEqual(res.status, FOUND)
self.assertEqual(res.path, os.path.realpath(expected))
def test_versioned_marketplace_layout_is_discovered(self):
# Sanitized mirror of a real marketplace install. Observed layout on a
# working machine: every plugin skills dir sits at
# <cache>/<marketplace>/<plugin>/<version>/skills — the unversioned
# layout did not occur at all, so discovery must handle this one.
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
cache = os.path.join(claude_home, "plugins", "cache")
expected = _write_skill(
os.path.join(cache, "claude-plugins-official", "superpowers", "5.0.7", "skills"),
"brainstorming",
)
cfg = load_config(claude_home=claude_home)
self.assertIn(
os.path.join(cache, "claude-plugins-official", "superpowers", "5.0.7", "skills"),
skill_search_roots(cfg),
)
res = resolve_skill("brainstorming", skill_search_roots(cfg))
self.assertEqual(res.status, FOUND)
self.assertEqual(res.path, os.path.realpath(expected))
def test_multiple_installed_versions_resolve_to_the_newest_not_ambiguous(self):
# A plugin upgraded in place keeps older version dirs alongside the new
# one (observed: three versions of the same plugin). Treating each as a
# peer root would make an ordinary upgrade resolve AMBIGUOUS.
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
plugin = os.path.join(claude_home, "plugins", "cache",
"claude-plugins-official", "chrome-devtools-mcp")
for version in ["1.1.1", "1.5.0", "1.6.0"]:
_write_skill(os.path.join(plugin, version, "skills"), "chrome-devtools")
newest = os.path.join(plugin, "1.6.0", "skills")
cfg = load_config(claude_home=claude_home)
roots = skill_search_roots(cfg)
self.assertEqual([r for r in roots if r.startswith(plugin)], [newest])
res = resolve_skill("chrome-devtools", roots)
self.assertEqual(res.status, FOUND)
self.assertEqual(res.path,
os.path.realpath(os.path.join(newest, "chrome-devtools", "SKILL.md")))
def test_version_ordering_is_numeric_not_lexicographic(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
plugin = os.path.join(claude_home, "plugins", "cache", "market", "plugin")
for version in ["1.9.0", "1.10.0"]:
_write_skill(os.path.join(plugin, version, "skills"), "example-skill")
cfg = load_config(claude_home=claude_home)
roots = [r for r in skill_search_roots(cfg) if r.startswith(plugin)]
# "1.10.0" < "1.9.0" as strings; it must still win as a version.
self.assertEqual(roots, [os.path.join(plugin, "1.10.0", "skills")])
def test_stable_release_beats_an_installed_prerelease(self):
# Segment lists alone would rank 2.0.0-beta above 2.0.0, because a
# shorter list that prefixes a longer one sorts lower. A prerelease
# must never be preferred over the stable release it precedes.
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
plugin = os.path.join(claude_home, "plugins", "cache", "market", "plugin")
for version in ["2.0.0", "2.0.0-beta"]:
_write_skill(os.path.join(plugin, version, "skills"), "example-skill")
cfg = load_config(claude_home=claude_home)
roots = [r for r in skill_search_roots(cfg) if r.startswith(plugin)]
self.assertEqual(roots, [os.path.join(plugin, "2.0.0", "skills")])
def test_version_key_orders_release_forms_sensibly(self):
from skillopt_sleep.skill_resolver import _version_sort_key as key
self.assertGreater(key("2.0.0"), key("2.0.0-beta"))
self.assertGreater(key("1.10.0"), key("1.9.0"))
self.assertGreater(key("2.0.0-beta"), key("2.0.0-alpha"))
self.assertGreater(key("1.0.0"), key("1.0"))
self.assertGreater(key("1.0.0"), key("1.0.0rc1"))
self.assertGreater(key("1.0.0"), key("main"))
def test_legacy_unversioned_layout_still_works(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
plugin = os.path.join(claude_home, "plugins", "cache", "market", "plugin")
expected = _write_skill(os.path.join(plugin, "skills"), "example-skill")
cfg = load_config(claude_home=claude_home)
self.assertIn(os.path.join(plugin, "skills"), skill_search_roots(cfg))
res = resolve_skill("example-skill", skill_search_roots(cfg))
self.assertEqual(res.status, FOUND)
self.assertEqual(res.path, os.path.realpath(expected))
def test_two_marketplaces_each_contribute_a_root(self):
with tempfile.TemporaryDirectory() as tmp:
claude_home = os.path.join(tmp, ".claude")
cache = os.path.join(claude_home, "plugins", "cache")
official = os.path.join(cache, "claude-plugins-official", "sentry", "1.0.0", "skills")
cognee = os.path.join(cache, "cognee", "cognee-memory", "1.0.0", "skills")
_write_skill(official, "sentry-skill")
_write_skill(cognee, "cognee-remember")
cfg = load_config(claude_home=claude_home)
roots = skill_search_roots(cfg)
self.assertIn(official, roots)
self.assertIn(cognee, roots)
def test_legacy_target_skill_path_behavior_is_untouched(self):
with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "repo", "SKILL.md")
cfg = load_config(claude_home=os.path.join(tmp, ".claude"),
target_skill_path=target)
self.assertEqual(cfg.managed_skill_path(), os.path.abspath(target))
if __name__ == "__main__":
unittest.main()