nanobot/tests/webui/test_skills_marketplace.py

562 lines
17 KiB
Python

import hashlib
import io
import zipfile
from pathlib import Path
from typing import Any
import httpx
import pytest
from nanobot.webui.skills_marketplace import (
SkillsMarketplaceError,
_valid_skillhub_download_url,
_validated_skillhub_entries,
install_marketplace_skill,
marketplace_skill_trends,
search_marketplace_skills,
trending_marketplace_skills,
)
@pytest.mark.asyncio
async def test_search_marketplace_skills_filters_and_marks_installed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: react-testing\n---\n", encoding="utf-8")
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "React Testing",
"skillId": "react-testing",
"source": "acme/agent-skills",
"installs": 42,
},
{"skillId": "../escape", "source": "acme/agent-skills"},
{"skillId": "valid-name", "source": "not-a-repository"},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.skills_install_supported",
lambda: True,
)
payload = await search_marketplace_skills(
" react testing ",
tmp_path,
provider="skills_sh",
)
assert seen == {
"url": "https://skills.sh/api/search",
"params": {"q": "react testing", "limit": 20},
}
assert payload == {
"query": "react testing",
"provider": "skills_sh",
"install_supported": True,
"skills": [
{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"provider": "skills_sh",
"installs": 42,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": True,
"install_supported": True,
"metric": "installs_total",
}
],
}
@pytest.mark.asyncio
async def test_search_skillhub_skills_normalizes_provider_metadata(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"results": [
{
"slug": "ima-skills",
"name": "ima-skills",
"namespace": {"handle": "tencent-adm"},
"source": "enterprise",
"version": "1.1.8",
"installs": 11831,
"downloads": 142525,
"publisher": {"verified": True},
"labels": {"requires_api_key": "true"},
}
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await search_marketplace_skills(
" ima ",
tmp_path,
provider="skillhub",
)
assert seen == {
"url": "https://api.skillhub.cn/api/v1/search",
"params": {"q": "ima", "limit": 20},
}
assert payload["provider"] == "skillhub"
assert payload["skills"] == [
{
"id": "skillhub:ima-skills",
"skill_id": "ima-skills",
"name": "ima-skills",
"source": "@tencent-adm/ima-skills",
"provider": "skillhub",
"installs": 11831,
"downloads": 142525,
"url": "https://skillhub.cn/tencent-adm/ima-skills",
"installed": False,
"install_supported": True,
"metric": "installs_total",
"version": "1.1.8",
"verified": True,
"requires_api_key": True,
}
]
@pytest.mark.asyncio
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "First",
"skillId": "first",
"source": "acme/skills",
"installs": 50,
},
{
"name": "Second from same source",
"skillId": "second",
"source": "acme/skills",
"installs": 49,
},
{
"name": "Another",
"skillId": "another",
"source": "other/skills",
"installs": 30,
},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://skills.sh/api/skills/trending/0"
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await trending_marketplace_skills(tmp_path, provider="skills_sh")
assert payload["period"] == "24h"
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
("First", 1),
("Another", 3),
]
@pytest.mark.asyncio
async def test_marketplace_skill_trends_returns_history_separately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
text = r"<script>\"values\":[3,5,8,13]</script>"
def raise_for_status(self) -> None:
pass
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://www.skills.sh/other/skills/second"
return FakeResponse()
async def weekly_installs(_client: object) -> dict[tuple[str, str], list[int]]:
return {
("acme/skills", "first"): [2, 4, 3, 8],
}
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace._load_weekly_installs",
weekly_installs,
)
assert await marketplace_skill_trends(
[
"acme/skills/first",
"other/skills/second",
"invalid",
]
) == {
"trends": {
"acme/skills/first": [2, 4, 3, 8],
"other/skills/second": [3, 5, 8, 13],
}
}
@pytest.mark.asyncio
async def test_search_marketplace_skills_returns_safe_upstream_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FailingClient:
async def __aenter__(self) -> "FailingClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, *_args: object, **_kwargs: object) -> None:
raise httpx.ConnectError("private network detail")
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FailingClient(),
)
with pytest.raises(SkillsMarketplaceError) as exc_info:
await search_marketplace_skills("react", tmp_path, provider="skills_sh")
assert exc_info.value.status == 502
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
@pytest.mark.asyncio
async def test_install_marketplace_skill_uses_official_cli_and_workspace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, None]:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\n---\n",
encoding="utf-8",
)
return b"installed", None
def kill(self) -> None:
raise AssertionError("successful install must not be killed")
async def create_subprocess_exec(*command: str, **kwargs: object) -> FakeProcess:
seen.update(command=command, **kwargs)
return FakeProcess()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.shutil.which",
lambda executable: "/usr/local/bin/npx" if executable == "npx" else None,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
result = await install_marketplace_skill(
"acme/agent-skills",
"react-testing",
tmp_path,
)
assert result == {
"installed": True,
"already_installed": False,
"name": "react-testing",
}
assert seen["command"] == (
"/usr/local/bin/npx",
"--yes",
"skills@latest",
"add",
"acme/agent-skills",
"--skill",
"react-testing",
"--agent",
"openclaw",
"--copy",
"--yes",
)
assert seen["cwd"] == str(tmp_path.resolve())
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
@pytest.mark.asyncio
async def test_install_skillhub_skill_checks_fingerprint_and_extracts_safely(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
archive_buffer = io.BytesIO()
skill_content = b"---\nname: ima-skills\ndescription: Tencent knowledge skill.\n---\n"
with zipfile.ZipFile(archive_buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("SKILL.md", skill_content)
archive.writestr("_meta.json", b'{"version":"1.1.8"}')
archive_bytes = archive_buffer.getvalue()
file_hash = hashlib.sha256(skill_content).hexdigest()
content_hash = hashlib.sha256(f"SKILL.md:{file_hash}\n".encode()).hexdigest()
class FakeResponse:
def __init__(
self,
*,
payload: dict[str, Any] | None = None,
status_code: int = 200,
headers: dict[str, str] | None = None,
content: bytes = b"",
) -> None:
self.payload = payload or {}
self.status_code = status_code
self.headers = headers or {}
self.content = content
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise httpx.HTTPStatusError(
"failed",
request=httpx.Request("GET", "https://example.com"),
response=httpx.Response(self.status_code),
)
def json(self) -> dict[str, Any]:
return self.payload
async def __aenter__(self) -> "FakeResponse":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def aiter_bytes(self):
yield self.content[:12]
yield self.content[12:]
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(
self,
url: str,
*,
params: dict[str, str] | None = None,
) -> FakeResponse:
if url.endswith("/signature"):
return FakeResponse(payload={"signed": True, "content_hash": content_hash})
assert url == "https://api.skillhub.cn/api/v1/download"
assert params == {"slug": "ima-skills", "version": "1.1.8"}
return FakeResponse(
status_code=302,
headers={
"location": (
"https://skillhub-1388575217.cos.accelerate.myqcloud.com/"
"skills/ima-skills.zip"
)
},
)
def stream(
self,
method: str,
url: str,
*,
headers: dict[str, str],
) -> FakeResponse:
assert method == "GET"
assert url.endswith("/skills/ima-skills.zip")
assert "application/zip" in headers["Accept"]
return FakeResponse(
headers={"content-length": str(len(archive_bytes))},
content=archive_bytes,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
result = await install_marketplace_skill(
"",
"ima-skills",
tmp_path,
provider="skillhub",
version="1.1.8",
)
assert result == {
"installed": True,
"already_installed": False,
"name": "ima-skills",
"provider": "skillhub",
"version": "1.1.8",
}
assert (tmp_path / "skills" / "ima-skills" / "SKILL.md").read_bytes() == skill_content
@pytest.mark.parametrize(
("url", "valid"),
[
("https://skillhub.cos.myqcloud.com/skills/example.zip", True),
("https://skillhub.cos.myqcloud.com:443/skills/example.zip", True),
("http://skillhub.cos.myqcloud.com/skills/example.zip", False),
("https://myqcloud.com/skills/example.zip", False),
("https://skillhub.cos.myqcloud.com.evil.example/skill.zip", False),
("https://user@skillhub.cos.myqcloud.com/skill.zip", False),
("https://skillhub.cos.myqcloud.com:not-a-port/skill.zip", False),
],
)
def test_skillhub_download_url_allows_only_pinned_cloud_hosts(
url: str,
valid: bool,
) -> None:
assert _valid_skillhub_download_url(url) is valid
def test_skillhub_archive_rejects_path_traversal() -> None:
archive_buffer = io.BytesIO()
with zipfile.ZipFile(archive_buffer, "w") as archive:
archive.writestr("SKILL.md", "---\nname: safe\n---\n")
archive.writestr("../outside.sh", "#!/bin/sh\n")
archive_buffer.seek(0)
with zipfile.ZipFile(archive_buffer) as archive:
with pytest.raises(SkillsMarketplaceError, match="unsafe path"):
_validated_skillhub_entries(archive)
@pytest.mark.asyncio
async def test_install_marketplace_skill_is_idempotent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "already-here"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: already-here\n---\n", encoding="utf-8")
launch = pytest.fail
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
launch,
)
result = await install_marketplace_skill("acme/agent-skills", "already-here", tmp_path)
assert result == {
"installed": True,
"already_installed": True,
"name": "already-here",
}
@pytest.mark.asyncio
async def test_install_marketplace_skill_rejects_symlinked_skills_root(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
try:
(workspace / "skills").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
with pytest.raises(SkillsMarketplaceError) as exc_info:
await install_marketplace_skill(
"",
"ima-skills",
workspace,
provider="skillhub",
version="1.1.8",
)
assert exc_info.value.status == 403
assert list(outside.iterdir()) == []