mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
562 lines
17 KiB
Python
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()) == []
|