Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

303 lines
9.5 KiB
Python

from __future__ import annotations
import importlib
from pathlib import Path
from types import SimpleNamespace
import pytest
from yuxi.agents.skills import service as skill_service
from yuxi.agents.toolkits.buildin import install_skill as exported_install_skill
install_skill_module = importlib.import_module("yuxi.agents.toolkits.buildin.install_skill")
class _AsyncSessionContext:
def __init__(self, db):
self.db = db
async def __aenter__(self):
return self.db
async def __aexit__(self, *_args):
return False
def _runtime(**context_values):
return SimpleNamespace(context=SimpleNamespace(**context_values))
@pytest.mark.asyncio
async def test_install_skill_from_sandbox_installs_as_current_user_private_skill(monkeypatch, tmp_path: Path):
assert exported_install_skill.name == "install_skill"
calls = {}
db = SimpleNamespace()
source_dir = tmp_path / "demo-skill"
def prepare_skill_from_sandbox(source, thread_id, uid, staging_root):
calls["prepare"] = {
"source": source,
"thread_id": thread_id,
"uid": uid,
"staging_root": staging_root,
}
return source_dir, "demo-skill"
async def import_skill_dir(db_arg, **kwargs):
calls["import"] = {"db": db_arg, **kwargs}
return SimpleNamespace(slug="demo-skill")
async def enable_skills(db_arg, thread_id, uid, skill_slugs):
calls["enable"] = {"db": db_arg, "thread_id": thread_id, "uid": uid, "skill_slugs": skill_slugs}
return True
def sync_thread_readable_skills(thread_id, skills):
calls["sync"] = {"thread_id": thread_id, "skills": skills}
monkeypatch.setattr(
install_skill_module,
"_prepare_skill_from_sandbox",
prepare_skill_from_sandbox,
)
monkeypatch.setattr(
install_skill_module,
"_enable_skills_in_current_config",
enable_skills,
)
monkeypatch.setattr(
install_skill_module.pg_manager,
"get_async_session_context",
lambda: _AsyncSessionContext(db),
)
monkeypatch.setattr(skill_service, "import_skill_dir", import_skill_dir)
monkeypatch.setattr(skill_service, "sync_thread_readable_skills", sync_thread_readable_skills)
result = await install_skill_module._run_install_task(
" /home/gem/user-data/workspace/demo-skill ",
_runtime(uid="normal-user", thread_id="thread-1", skills=["existing-skill"]),
"tool-1",
)
assert result.update["activated_skills"] == ["demo-skill"]
assert "成功安装并激活技能" in result.update["messages"][0].content
assert calls["prepare"]["uid"] == "normal-user"
assert calls["import"]["created_by"] == "normal-user"
assert calls["import"]["share_config"] == {
"access_level": "user",
"department_ids": [],
"user_uids": ["normal-user"],
}
assert calls["prepare"]["source"] == "/home/gem/user-data/workspace/demo-skill"
assert calls["enable"] == {
"db": db,
"thread_id": "thread-1",
"uid": "normal-user",
"skill_slugs": ["demo-skill"],
}
assert calls["sync"] == {"thread_id": "thread-1", "skills": ["existing-skill", "demo-skill"]}
@pytest.mark.asyncio
async def test_install_skill_rejects_subagent_runtime_before_install(monkeypatch):
def fail_get_session():
raise AssertionError("子智能体运行态不应访问数据库或执行安装")
monkeypatch.setattr(
install_skill_module.pg_manager,
"get_async_session_context",
fail_get_session,
)
result = await install_skill_module._run_install_task(
"/home/gem/user-data/workspace/demo-skill",
_runtime(uid="user-1", thread_id="child-thread", is_subagent_runtime=True),
"tool-1",
)
assert "只能在主智能体中使用" in result.update["messages"][0].content
assert "activated_skills" not in result.update
@pytest.mark.asyncio
async def test_install_skill_git_source_requires_skill_names():
result = await install_skill_module._run_install_task(
"owner/repo",
_runtime(uid="user-1", thread_id="thread-1"),
"tool-1",
)
assert "必须通过 skill_names 指定技能名称" in result.update["messages"][0].content
@pytest.mark.asyncio
async def test_install_skill_rejects_empty_source():
result = await install_skill_module._run_install_task(
" ",
_runtime(uid="user-1", thread_id="thread-1"),
"tool-1",
)
assert "Skill 来源不能为空" in result.update["messages"][0].content
@pytest.mark.asyncio
async def test_enable_skills_updates_current_user_owned_agent_config(monkeypatch):
conv = SimpleNamespace(uid="user-1", agent_id="agent-1")
agent = SimpleNamespace(
created_by="user-1",
config_json={"context": {"skills": ["existing-skill"], "model": "provider:model"}},
)
calls = {}
class FakeConversationRepository:
def __init__(self, db):
self.db = db
async def get_conversation_by_thread_id(self, thread_id):
calls["thread_id"] = thread_id
return conv
class FakeAgentRepository:
def __init__(self, db):
self.db = db
async def get_by_slug(self, slug):
calls["agent_slug"] = slug
return agent
async def update(self, agent_arg, **kwargs):
calls["update"] = {"agent": agent_arg, **kwargs}
return agent_arg
monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository)
monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository)
result = await install_skill_module._enable_skills_in_current_config(
SimpleNamespace(),
"thread-1",
"user-1",
["existing-skill", "new-skill"],
)
assert result is True
assert calls["thread_id"] == "thread-1"
assert calls["agent_slug"] == "agent-1"
assert calls["update"]["updated_by"] == "user-1"
assert calls["update"]["config_json"] == {
"context": {"skills": ["existing-skill", "new-skill"], "model": "provider:model"}
}
@pytest.mark.asyncio
async def test_enable_skills_does_not_update_agent_not_owned_by_current_user(monkeypatch):
conv = SimpleNamespace(uid="user-1", agent_id="shared-agent")
agent = SimpleNamespace(created_by="admin", config_json={"context": {}})
calls = {}
class FakeConversationRepository:
def __init__(self, db):
self.db = db
async def get_conversation_by_thread_id(self, _thread_id):
return conv
class FakeAgentRepository:
def __init__(self, db):
self.db = db
async def get_by_slug(self, _slug):
return agent
async def update(self, *_args, **_kwargs):
calls["updated"] = True
monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository)
monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository)
result = await install_skill_module._enable_skills_in_current_config(
SimpleNamespace(),
"thread-1",
"user-1",
["new-skill"],
)
assert result is False
assert "updated" not in calls
@pytest.mark.asyncio
async def test_enable_skills_does_not_update_mismatched_runtime_uid(monkeypatch):
conv = SimpleNamespace(uid="user-1", agent_id="agent-1")
calls = {}
class FakeConversationRepository:
def __init__(self, db):
self.db = db
async def get_conversation_by_thread_id(self, _thread_id):
return conv
class FakeAgentRepository:
def __init__(self, db):
self.db = db
async def get_by_slug(self, *_args):
calls["loaded_agent"] = True
monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository)
monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository)
result = await install_skill_module._enable_skills_in_current_config(
SimpleNamespace(),
"thread-1",
"other-user",
["new-skill"],
)
assert result is False
assert "loaded_agent" not in calls
def test_prepare_skill_invalid_virtual_path_does_not_fallback_to_sandbox(monkeypatch, tmp_path: Path):
calls = {}
def resolve_virtual_path(*_args, **_kwargs):
raise ValueError("path traversal detected")
class FakeProvisionerSandboxBackend:
def __init__(self, *_args, **_kwargs):
calls["fallback"] = True
monkeypatch.setattr(
"yuxi.agents.backends.sandbox.resolve_virtual_path",
resolve_virtual_path,
)
monkeypatch.setattr(
"yuxi.agents.backends.sandbox.ProvisionerSandboxBackend",
FakeProvisionerSandboxBackend,
)
monkeypatch.setattr(skill_service, "is_valid_skill_slug", lambda _slug: True)
with pytest.raises(ValueError, match="path traversal detected"):
install_skill_module._prepare_skill_from_sandbox(
"/home/gem/user-data/workspace/demo-skill",
"thread-1",
"user-1",
tmp_path,
)
assert "fallback" not in calls
def test_collect_sandbox_file_paths_rejects_more_than_1000_files():
class FakeBackend:
def ls(self, _remote_dir):
return SimpleNamespace(
error=None,
entries=[{"path": f"/skill/file-{idx}.txt", "is_dir": False} for idx in range(1001)],
)
with pytest.raises(ValueError, match="最多 1000 个文件"):
install_skill_module._collect_sandbox_file_paths(FakeBackend(), "/skill")