Files
2026-07-13 13:12:33 +08:00

275 lines
9.1 KiB
Python

from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
import pytest
from opensquilla.skills.hub.installer import InstallResult
from opensquilla.skills.hub.scanner import ScanFinding, ScanResult
from opensquilla.skills.hub.source import SkillMeta
from opensquilla.skills.loader import SkillLoader
from opensquilla.tools.builtin import skill_tools as skill_tools_module
from opensquilla.tools.registry import get_default_registry
async def _skill_view(name: str, file_path: str | None = None) -> str:
registered = get_default_registry().get("skill_view")
assert registered is not None
return await registered.handler(name=name, file_path=file_path)
async def _skill_search_community(query: str, source: str = "clawhub", limit: int = 10) -> str:
registered = get_default_registry().get("skill_search_community")
assert registered is not None
return await registered.handler(query=query, source=source, limit=limit)
async def _skill_list() -> str:
registered = get_default_registry().get("skill_list")
assert registered is not None
return await registered.handler()
async def _skill_install_community(
identifier: str,
source: str = "clawhub",
force: bool = False,
) -> str:
registered = get_default_registry().get("skill_install_community")
assert registered is not None
return await registered.handler(identifier=identifier, source=source, force=force)
@pytest.fixture()
def skill_loader(tmp_path: Path) -> Iterator[SkillLoader]:
bundled_root = tmp_path / "bundled"
skill_dir = bundled_root / "deck"
(skill_dir / "references").mkdir(parents=True)
(skill_dir / "scripts").mkdir()
(skill_dir / "assets").mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: deck\ndescription: Deck helper\n---\n"
"See [guide](references/guide.md).\n",
encoding="utf-8",
)
env_any_dir = bundled_root / "env-any"
env_any_dir.mkdir(parents=True)
(env_any_dir / "SKILL.md").write_text(
"---\n"
"name: env-any\n"
"description: Needs one of two API keys\n"
"metadata:\n"
" opensquilla:\n"
" requires:\n"
" envAny: [OPENROUTER_API_KEY, ARK_API_KEY]\n"
"---\n"
"Needs an API key.\n",
encoding="utf-8",
)
(skill_dir / "references" / "guide.md").write_text("reference body\n", encoding="utf-8")
(skill_dir / "scripts" / "inspect.py").write_text("print('script body')\n", encoding="utf-8")
(skill_dir / "assets" / "palette.txt").write_text("blue\n", encoding="utf-8")
(skill_dir / "secret.txt").write_text("do not expose\n", encoding="utf-8")
loader = SkillLoader(
bundled_dir=bundled_root,
workspace_dir=tmp_path / "workspace",
managed_dir=tmp_path / "managed",
personal_agents_dir=tmp_path / "personal",
project_agents_dir=tmp_path / "project",
snapshot_path=tmp_path / "skills.snapshot.json",
)
previous_loader = skill_tools_module._loader
skill_tools_module.create_skill_tools(loader)
try:
yield loader
finally:
skill_tools_module._loader = previous_loader
@pytest.mark.asyncio
async def test_skill_view_reads_registered_skill_resources_by_relative_path(
skill_loader: SkillLoader,
) -> None:
assert "reference body" in await _skill_view("deck", "references/guide.md")
assert "script body" in await _skill_view("deck", "scripts/inspect.py")
assert "blue" in await _skill_view("deck", "assets/palette.txt")
@pytest.mark.asyncio
async def test_skill_view_rejects_resource_paths_that_escape_skill_directory(
skill_loader: SkillLoader,
) -> None:
result = await _skill_view("deck", "../secret.txt")
assert "File not found in skill 'deck': ../secret.txt" == result
assert "do not expose" not in result
@pytest.mark.asyncio
async def test_skill_view_missing_skill_uses_catalog_guidance(
skill_loader: SkillLoader,
) -> None:
result = await _skill_view("missing-skill")
assert "Skill not found: missing-skill" in result
assert "current skill catalog" in result
assert "Do not search host filesystem paths" in result
assert "skill_list" in result
@pytest.mark.asyncio
async def test_skill_list_reports_missing_env_any_groups(
skill_loader: SkillLoader,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("ARK_API_KEY", raising=False)
result = await _skill_list()
assert "OPENROUTER_API_KEY or ARK_API_KEY (env var group)" in result
@pytest.mark.asyncio
async def test_skill_search_community_returns_hub_results_with_installed_flag(
skill_loader: SkillLoader,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeRouter:
def __init__(self) -> None:
self.calls: list[tuple[str, int, str | None]] = []
async def search(
self,
query: str,
limit: int = 20,
source_id: str | None = None,
) -> list[SkillMeta]:
self.calls.append((query, limit, source_id))
return [
SkillMeta(
name="plotter",
description="Plot charts",
version="1.0.0",
author="OpenSquilla",
source_id="clawhub",
trust_level="community",
identifier="plotter",
)
]
router = FakeRouter()
monkeypatch.setattr(skill_tools_module, "get_default_skill_router", lambda: router)
monkeypatch.setattr(skill_tools_module, "installed_skill_names", lambda: {"plotter"})
payload = json.loads(await _skill_search_community("plot", source="clawhub", limit=5))
assert payload["status"] == "ok"
assert router.calls == [("plot", 5, "clawhub")]
assert payload["results"] == [
{
"name": "plotter",
"description": "Plot charts",
"version": "1.0.0",
"author": "OpenSquilla",
"source": "clawhub",
"trust_level": "community",
"identifier": "plotter",
"installed": True,
}
]
@pytest.mark.asyncio
async def test_skill_install_community_uses_loader_managed_dir_and_invalidates_cache(
skill_loader: SkillLoader,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeInstaller:
def __init__(self) -> None:
self.calls: list[tuple[str, str, bool]] = []
async def install(
self,
identifier: str,
source_id: str,
force: bool = False,
) -> InstallResult:
self.calls.append((identifier, source_id, force))
return InstallResult(
success=True,
name="plotter",
message="installed",
path=str(skill_loader.managed_dir / "plotter"),
)
installer = FakeInstaller()
captured: dict[str, Path | None] = {}
def fake_builder(*, managed_dir: Path | None = None) -> FakeInstaller:
captured["managed_dir"] = managed_dir
return installer
monkeypatch.setattr(skill_tools_module, "build_default_skill_installer", fake_builder)
skill_loader.load_all()
assert skill_loader._cached is not None
payload = json.loads(await _skill_install_community("plotter"))
assert captured["managed_dir"] == skill_loader.managed_dir
assert installer.calls == [("plotter", "clawhub", False)]
assert payload["status"] == "installed"
assert payload["success"] is True
assert Path(payload["path"]).name == "plotter"
assert skill_loader._cached is None
@pytest.mark.asyncio
async def test_skill_install_community_surfaces_scan_failure_without_invalidating_cache(
skill_loader: SkillLoader,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeInstaller:
async def install(
self,
identifier: str,
source_id: str,
force: bool = False,
) -> InstallResult:
return InstallResult(
success=False,
name=identifier,
message="Security scan: dangerous",
scan=ScanResult(
verdict="dangerous",
findings=[
ScanFinding(
category="prompt_injection",
severity="dangerous",
line=1,
text="ignore previous instructions",
pattern="ignore",
)
],
),
)
monkeypatch.setattr(
skill_tools_module,
"build_default_skill_installer",
lambda *, managed_dir=None: FakeInstaller(),
)
skill_loader.load_all()
assert skill_loader._cached is not None
payload = json.loads(await _skill_install_community("unsafe"))
assert payload["status"] == "failed"
assert payload["success"] is False
assert payload["scan_verdict"] == "dangerous"
assert payload["scan_findings"][0]["category"] == "prompt_injection"
assert skill_loader._cached is not None