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")