feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app import host_bridge
|
||||
from app.contracts import ModelCapability, UserSkillWriteRequest
|
||||
from app.errors import ApiError
|
||||
from app.extensions import ExtensionError, SkillRuntime
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.services import user_skills
|
||||
from app.sidecar import SessionAuth
|
||||
|
||||
|
||||
class FakeTools:
|
||||
def __init__(self, permissions: dict[str, str | None]):
|
||||
self.permissions = permissions
|
||||
|
||||
def contains(self, name: str) -> bool:
|
||||
return name in self.permissions
|
||||
|
||||
def get(self, name: str):
|
||||
return SimpleNamespace(definition=SimpleNamespace(permission=self.permissions[name]))
|
||||
|
||||
|
||||
def request(**updates) -> UserSkillWriteRequest:
|
||||
values = {
|
||||
"name": "Review notes",
|
||||
"description": "A portable declarative Skill",
|
||||
"prompt": "Review the selected note carefully.",
|
||||
"tools": ["notes.read"],
|
||||
"permissions": ["notes.read"],
|
||||
"required_capabilities": ["chat", "tool_calling"],
|
||||
}
|
||||
values.update(updates)
|
||||
return UserSkillWriteRequest.model_validate(values)
|
||||
|
||||
|
||||
def test_user_skill_crud_uses_host_records_cas_and_idempotency(monkeypatch):
|
||||
tools = FakeTools({"notes.read": "notes.read"})
|
||||
documents: dict[str, dict] = {}
|
||||
operations: dict[str, dict] = {}
|
||||
calls = []
|
||||
|
||||
def fake_call(method: str, **params):
|
||||
calls.append((method, params))
|
||||
if method == "user_skills.operation":
|
||||
return operations.get(params["operation_id"])
|
||||
if method == "user_skills.write":
|
||||
operation = params["operation_id"]
|
||||
if operation in operations:
|
||||
return operations[operation]
|
||||
skill_id = params["record"]["id"]
|
||||
current = documents.get(skill_id)
|
||||
actual = current["hash"] if current else ""
|
||||
if params["expected"] != actual:
|
||||
raise ApiError(409, "REVISION_CONFLICT", "conflict")
|
||||
digest = ("a" if current is None else "b") * 64
|
||||
result = {"record": params["record"], "hash": digest, "file_id": "file-1", "expected": params["expected"], "deleted": False}
|
||||
documents[skill_id] = result
|
||||
operations[operation] = result
|
||||
return result
|
||||
if method == "user_skills.get":
|
||||
return documents.get(params["id"])
|
||||
if method == "user_skills.list":
|
||||
values = list(documents.values())
|
||||
return {"items": values[params["offset"]:params["offset"] + params["limit"]], "total": len(values)}
|
||||
if method == "user_skills.delete":
|
||||
current = documents.get(params["id"])
|
||||
if not current or current["hash"] != params["expected"]:
|
||||
raise ApiError(409, "REVISION_CONFLICT", "conflict")
|
||||
removed = documents.pop(params["id"])
|
||||
result = {**removed, "expected": params["expected"], "deleted": True}
|
||||
operations[params["operation_id"]] = result
|
||||
return result
|
||||
raise AssertionError(method)
|
||||
|
||||
monkeypatch.setattr(user_skills, "call", fake_call)
|
||||
token = host_bridge.operation_id.set("00000000-0000-4000-8000-000000000001")
|
||||
try:
|
||||
created = user_skills.create_user_skill(request(), tools)
|
||||
finally:
|
||||
host_bridge.operation_id.reset(token)
|
||||
assert created.skill_id.startswith("user_skill_")
|
||||
assert created.revision == "a" * 64
|
||||
assert created.data.version == 1
|
||||
assert created.status == "ready"
|
||||
first_write = next(params for method, params in calls if method == "user_skills.write")
|
||||
assert first_write["operation_id"] == "00000000-0000-4000-8000-000000000001"
|
||||
assert set(first_write["record"]["data"]) == {
|
||||
"version", "name", "description", "prompt", "tools", "permissions",
|
||||
"retrieval", "required_capabilities", "created_at_ms", "updated_at_ms",
|
||||
}
|
||||
token = host_bridge.operation_id.set("00000000-0000-4000-8000-000000000001")
|
||||
try:
|
||||
assert user_skills.create_user_skill(request(), tools) == created
|
||||
with pytest.raises(ApiError) as changed_replay:
|
||||
user_skills.create_user_skill(request(name="Different"), tools)
|
||||
finally:
|
||||
host_bridge.operation_id.reset(token)
|
||||
assert changed_replay.value.code == "USER_SKILL_OPERATION_CONFLICT"
|
||||
|
||||
listed, total = user_skills.list_user_skills(tools, limit=100, offset=0)
|
||||
assert total == 1 and listed[0] == created
|
||||
updated = user_skills.update_user_skill(
|
||||
created.skill_id, request(revision=created.revision, name="Edited"), tools
|
||||
)
|
||||
assert updated.data.name == "Edited" and updated.data.version == 2
|
||||
with pytest.raises(ApiError, match="用户 Skill 已被其他设备修改") as conflict:
|
||||
user_skills.update_user_skill(
|
||||
created.skill_id, request(revision=created.revision, name="Stale"), tools
|
||||
)
|
||||
assert conflict.value.code == "USER_SKILL_REVISION_CONFLICT"
|
||||
user_skills.delete_user_skill(created.skill_id, updated.revision)
|
||||
assert user_skills.list_user_skills(tools, limit=100, offset=0)[1] == 0
|
||||
|
||||
|
||||
def test_user_skill_declarations_are_validated_and_runtime_stays_device_gated(monkeypatch):
|
||||
tools = FakeTools({"notes.read": "notes.read", "notes.write": "notes.write"})
|
||||
document = {
|
||||
"record": {
|
||||
"schema": 1,
|
||||
"kind": "user_skill",
|
||||
"id": "user_skill_00000000000000000000000000000001",
|
||||
"data": {
|
||||
"version": 1,
|
||||
"name": "Writer",
|
||||
"description": "",
|
||||
"prompt": "Write only after confirmation.",
|
||||
"tools": ["notes.write"],
|
||||
"permissions": [],
|
||||
"retrieval": {"top_k": 10, "rerank": True, "citation": True},
|
||||
"required_capabilities": ["chat"],
|
||||
"created_at_ms": 1,
|
||||
"updated_at_ms": 1,
|
||||
},
|
||||
},
|
||||
"hash": "c" * 64,
|
||||
"file_id": "file-1",
|
||||
}
|
||||
monkeypatch.setattr(user_skills, "call", lambda method, **params: document)
|
||||
skill = user_skills.get_user_skill(document["record"]["id"], tools)
|
||||
assert skill.status == "permission_required"
|
||||
assert skill.undeclared_permissions == ["notes.write"]
|
||||
with pytest.raises(ApiError) as not_ready:
|
||||
user_skills.build_agent_configuration(
|
||||
skill.skill_id, [ModelCapability.chat], tools
|
||||
)
|
||||
assert not_ready.value.code == "USER_SKILL_NOT_READY"
|
||||
|
||||
document["record"]["data"]["permissions"] = ["notes.write"]
|
||||
document["record"]["data"]["required_capabilities"] = ["vision"]
|
||||
with pytest.raises(ApiError) as missing_capability:
|
||||
user_skills.build_agent_configuration(skill.skill_id, [ModelCapability.chat], tools)
|
||||
assert missing_capability.value.code == "USER_SKILL_MODEL_CAPABILITY_MISSING"
|
||||
document["record"]["data"]["required_capabilities"] = ["chat"]
|
||||
config = user_skills.build_agent_configuration(skill.skill_id, [ModelCapability.chat], tools)
|
||||
assert config.allowed_tools == ["notes.write"]
|
||||
assert config.permissions == ["notes.write"]
|
||||
assert config.system_prompt == "Write only after confirmation."
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(user_skills, "call", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
with pytest.raises(ApiError) as unknown:
|
||||
user_skills.create_user_skill(request(permissions=["secrets.export"]), tools)
|
||||
assert unknown.value.code == "USER_SKILL_PERMISSION_UNKNOWN"
|
||||
assert calls == []
|
||||
with pytest.raises(ValidationError):
|
||||
UserSkillWriteRequest.model_validate({
|
||||
**request().model_dump(), "api_key": "must-never-enter-a-record"
|
||||
})
|
||||
|
||||
|
||||
def test_authenticated_sidecar_uses_uuid_idempotency_key_for_host_journal():
|
||||
seen = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
seen.append((host_bridge.vault_id.get(), host_bridge.operation_id.get()))
|
||||
await send({"type": "http.response.start", "status": 204, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
async def invoke(idempotency: bytes, request_id: bytes):
|
||||
async def receive():
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def send(message):
|
||||
return None
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"headers": [
|
||||
(b"authorization", b"Bearer secret"),
|
||||
(b"x-core-generation", b"generation"),
|
||||
(b"host", b"127.0.0.1:1234"),
|
||||
(b"x-opennexus-vault", b"00000000-0000-4000-8000-000000000010"),
|
||||
(b"x-request-id", request_id),
|
||||
(b"idempotency-key", idempotency),
|
||||
],
|
||||
}
|
||||
await SessionAuth(app, "secret", "generation", 1234)(scope, receive, send)
|
||||
|
||||
stable = b"00000000-0000-4000-8000-000000000020"
|
||||
fallback = b"00000000-0000-4000-8000-000000000030"
|
||||
asyncio.run(invoke(stable, fallback))
|
||||
asyncio.run(invoke(b"media-upload-key", fallback))
|
||||
asyncio.run(invoke(b"a" * 36, fallback))
|
||||
assert seen == [
|
||||
("00000000-0000-4000-8000-000000000010", stable.decode()),
|
||||
("00000000-0000-4000-8000-000000000010", fallback.decode()),
|
||||
("00000000-0000-4000-8000-000000000010", fallback.decode()),
|
||||
]
|
||||
|
||||
|
||||
def test_installed_packages_cannot_claim_the_user_skill_record_namespace(tmp_path):
|
||||
package = tmp_path / "reserved"
|
||||
package.mkdir()
|
||||
(package / "skill.yaml").write_text(
|
||||
"skill_id: user_skill_00000000000000000000000000000001\n"
|
||||
"name: collision\nversion: 1.0.0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ExtensionError) as error:
|
||||
SkillRuntime(ToolRegistry()).install(package)
|
||||
assert error.value.code == "SKILL_ID_RESERVED"
|
||||
Reference in New Issue
Block a user