228 lines
9.5 KiB
Python
228 lines
9.5 KiB
Python
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"
|