feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -96,11 +96,21 @@ class AgentRuntime:
|
||||
provider = self.providers.get(request.provider_id)
|
||||
skill_config = None
|
||||
if request.skill_id:
|
||||
if self.skills is None:
|
||||
raise RuntimeError("Skill Runtime is not configured.")
|
||||
skill_config = self.skills.build_agent_configuration(
|
||||
request.skill_id, provider.config.capabilities
|
||||
)
|
||||
if request.skill_id.startswith("user_skill_"):
|
||||
from app.services.user_skills import build_agent_configuration
|
||||
|
||||
skill_config = await asyncio.to_thread(
|
||||
build_agent_configuration,
|
||||
request.skill_id,
|
||||
provider.config.capabilities,
|
||||
self.tools,
|
||||
)
|
||||
else:
|
||||
if self.skills is None:
|
||||
raise RuntimeError("Skill Runtime is not configured.")
|
||||
skill_config = self.skills.build_agent_configuration(
|
||||
request.skill_id, provider.config.capabilities
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
run = AgentRun(
|
||||
run_id=f"run_{uuid4().hex}",
|
||||
|
||||
@@ -502,6 +502,83 @@ class SkillListResponse(Contract):
|
||||
items: list[Skill] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserSkillData(Contract):
|
||||
version: int = Field(ge=1, le=9007199254740991)
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
prompt: str = Field(default="", max_length=64000)
|
||||
tools: list[str] = Field(default_factory=list, max_length=64)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=32)
|
||||
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
|
||||
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
|
||||
created_at_ms: int = Field(ge=0, le=253402300799999)
|
||||
updated_at_ms: int = Field(ge=0, le=253402300799999)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def user_skill_name_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("name must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("tools", "permissions")
|
||||
@classmethod
|
||||
def user_skill_identifiers(cls, values: list[str]) -> list[str]:
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("identifiers must be unique")
|
||||
if any(
|
||||
not value
|
||||
or len(value) > 128
|
||||
or any(not (char.isascii() and (char.isalnum() or char in "._-")) for char in value)
|
||||
for value in values
|
||||
):
|
||||
raise ValueError("identifier is invalid")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def user_skill_timestamps(self):
|
||||
if self.updated_at_ms < self.created_at_ms:
|
||||
raise ValueError("updated_at_ms precedes created_at_ms")
|
||||
return self
|
||||
|
||||
|
||||
class UserSkillWriteRequest(Contract):
|
||||
revision: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
prompt: str = Field(default="", max_length=64000)
|
||||
tools: list[str] = Field(default_factory=list, max_length=64)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=32)
|
||||
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
|
||||
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def user_skill_write_name_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("name must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("tools", "permissions")
|
||||
@classmethod
|
||||
def user_skill_write_identifiers(cls, values: list[str]) -> list[str]:
|
||||
return UserSkillData.user_skill_identifiers(values)
|
||||
|
||||
|
||||
class UserSkill(Contract):
|
||||
skill_id: str = Field(pattern=r"^user_skill_[0-9a-f]{32}$")
|
||||
revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
data: UserSkillData
|
||||
status: Literal["ready", "dependency_missing", "permission_required"]
|
||||
missing_dependencies: list[str] = Field(default_factory=list)
|
||||
undeclared_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserSkillListResponse(Contract):
|
||||
items: list[UserSkill] = Field(default_factory=list)
|
||||
page: PageMeta = Field(default_factory=PageMeta)
|
||||
|
||||
|
||||
class ExtensionInstallRequest(Contract):
|
||||
package_path: str
|
||||
|
||||
|
||||
@@ -100,6 +100,12 @@ class SkillRuntime:
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("skill", exc) from exc
|
||||
_validate_id("skill", manifest.skill_id)
|
||||
if manifest.skill_id.startswith("user_skill_"):
|
||||
raise ExtensionError(
|
||||
"SKILL_ID_RESERVED",
|
||||
"The user_skill_ prefix is reserved for Vault-owned user Skills.",
|
||||
status_code=422,
|
||||
)
|
||||
_validate_permissions("skill", manifest.permissions)
|
||||
if manifest.skill_id in self._records:
|
||||
raise ExtensionError(
|
||||
|
||||
@@ -95,6 +95,9 @@ from app.contracts import (
|
||||
SearchResponse,
|
||||
Skill,
|
||||
SkillListResponse,
|
||||
UserSkill,
|
||||
UserSkillListResponse,
|
||||
UserSkillWriteRequest,
|
||||
Task,
|
||||
TaskCreateRequest,
|
||||
TaskListResponse,
|
||||
@@ -677,6 +680,52 @@ async def list_tools() -> ToolListResponse:
|
||||
|
||||
|
||||
# Skills
|
||||
@router.get("/user-skills", response_model=UserSkillListResponse, tags=["Skills"])
|
||||
async def list_user_skills(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> UserSkillListResponse:
|
||||
from app.services.user_skills import list_user_skills as list_records
|
||||
|
||||
items, total = await asyncio.to_thread(
|
||||
list_records, container.tools, limit=limit, offset=offset
|
||||
)
|
||||
return UserSkillListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
|
||||
async def get_user_skill(skill_id: str) -> UserSkill:
|
||||
from app.services.user_skills import get_user_skill as get_record
|
||||
|
||||
return await asyncio.to_thread(get_record, skill_id, container.tools)
|
||||
|
||||
|
||||
@router.post("/user-skills", response_model=UserSkill, status_code=201, tags=["Skills"])
|
||||
async def create_user_skill(request: UserSkillWriteRequest) -> UserSkill:
|
||||
from app.services.user_skills import create_user_skill as create_record
|
||||
|
||||
return await asyncio.to_thread(create_record, request, container.tools)
|
||||
|
||||
|
||||
@router.put("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
|
||||
async def update_user_skill(skill_id: str, request: UserSkillWriteRequest) -> UserSkill:
|
||||
from app.services.user_skills import update_user_skill as update_record
|
||||
|
||||
return await asyncio.to_thread(update_record, skill_id, request, container.tools)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/user-skills/{skill_id}", response_model=OperationResponse, tags=["Skills"]
|
||||
)
|
||||
async def delete_user_skill(skill_id: str, revision: str = Query()) -> OperationResponse:
|
||||
from app.services.user_skills import delete_user_skill as delete_record
|
||||
|
||||
await asyncio.to_thread(delete_record, skill_id, revision)
|
||||
return OperationResponse(status="completed", resource_id=skill_id, message="deleted")
|
||||
|
||||
|
||||
@router.get("/skills", response_model=SkillListResponse, tags=["Skills"])
|
||||
async def list_skills() -> SkillListResponse:
|
||||
return SkillListResponse(items=container.skills.list())
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Vault-owned user Skill records and their declarative Agent configuration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from time import time_ns
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app import host_bridge
|
||||
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||
from app.contracts import ModelCapability, UserSkill, UserSkillData, UserSkillWriteRequest
|
||||
from app.errors import ApiError
|
||||
from app.extensions.runtime import AgentConfiguration
|
||||
from app.services.desktop_notes import call
|
||||
|
||||
|
||||
def _operation_id() -> str:
|
||||
return host_bridge.operation_id.get() or str(uuid4())
|
||||
|
||||
|
||||
def _validate_skill_id(skill_id: str) -> None:
|
||||
if not (
|
||||
skill_id.startswith("user_skill_")
|
||||
and len(skill_id) == 43
|
||||
and all(char in "0123456789abcdef" for char in skill_id[11:])
|
||||
):
|
||||
raise ApiError(422, "USER_SKILL_ID_INVALID", "用户 Skill 标识无效。")
|
||||
|
||||
|
||||
def _validate_declarations(request: UserSkillWriteRequest) -> None:
|
||||
unknown = sorted(set(request.permissions) - KNOWN_PERMISSIONS)
|
||||
if unknown:
|
||||
raise ApiError(
|
||||
422,
|
||||
"USER_SKILL_PERMISSION_UNKNOWN",
|
||||
"用户 Skill 声明了未知权限。",
|
||||
{"permissions": unknown},
|
||||
)
|
||||
|
||||
|
||||
def _state(data: UserSkillData, tools) -> tuple[str, list[str], list[str]]:
|
||||
missing = [name for name in data.tools if not tools.contains(name)]
|
||||
declared = set(data.permissions)
|
||||
required = {
|
||||
tools.get(name).definition.permission
|
||||
for name in data.tools
|
||||
if tools.contains(name) and tools.get(name).definition.permission
|
||||
}
|
||||
undeclared = sorted(permission for permission in required - declared if permission)
|
||||
status = "dependency_missing" if missing else "permission_required" if undeclared else "ready"
|
||||
return status, missing, undeclared
|
||||
|
||||
|
||||
def _public(document: dict, tools) -> UserSkill:
|
||||
data = UserSkillData.model_validate(document["record"]["data"])
|
||||
status, missing, undeclared = _state(data, tools)
|
||||
return UserSkill(
|
||||
skill_id=document["record"]["id"],
|
||||
revision=document["hash"],
|
||||
data=data,
|
||||
status=status,
|
||||
missing_dependencies=missing,
|
||||
undeclared_permissions=undeclared,
|
||||
)
|
||||
|
||||
|
||||
def _request_values(request: UserSkillWriteRequest) -> dict:
|
||||
return request.model_dump(exclude={"revision"}, mode="json")
|
||||
|
||||
|
||||
def _replay(operation_id: str, skill_id: str, request: UserSkillWriteRequest | None, expected: str):
|
||||
receipt = call("user_skills.operation", operation_id=operation_id)
|
||||
if receipt is None:
|
||||
return None
|
||||
data = receipt.get("record", {}).get("data", {})
|
||||
requested = {} if request is None else _request_values(request)
|
||||
mismatched_fields = sorted(
|
||||
key for key, value in requested.items() if data.get(key) != value
|
||||
)
|
||||
matches = (
|
||||
receipt.get("record", {}).get("kind") == "user_skill"
|
||||
and receipt.get("record", {}).get("id") == skill_id
|
||||
and receipt.get("expected") == expected
|
||||
and receipt.get("deleted") is (request is None)
|
||||
and not mismatched_fields
|
||||
)
|
||||
if not matches:
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_OPERATION_CONFLICT",
|
||||
"该幂等键已用于不同的用户 Skill 操作。",
|
||||
{
|
||||
"kind_matches": receipt.get("record", {}).get("kind") == "user_skill",
|
||||
"id_matches": receipt.get("record", {}).get("id") == skill_id,
|
||||
"expected_matches": receipt.get("expected") == expected,
|
||||
"operation_matches": receipt.get("deleted") is (request is None),
|
||||
"mismatched_fields": mismatched_fields,
|
||||
},
|
||||
)
|
||||
return receipt if request is not None else True
|
||||
|
||||
|
||||
def list_user_skills(tools, *, limit: int, offset: int) -> tuple[list[UserSkill], int]:
|
||||
page = call("user_skills.list", offset=offset, limit=limit)
|
||||
return [_public(item, tools) for item in page["items"]], page["total"]
|
||||
|
||||
|
||||
def get_user_skill(skill_id: str, tools) -> UserSkill:
|
||||
_validate_skill_id(skill_id)
|
||||
document = call("user_skills.get", id=skill_id)
|
||||
if document is None:
|
||||
raise ApiError(404, "USER_SKILL_NOT_FOUND", "用户 Skill 不存在。", {"skill_id": skill_id})
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def create_user_skill(request: UserSkillWriteRequest, tools) -> UserSkill:
|
||||
_validate_declarations(request)
|
||||
if request.revision:
|
||||
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "新建用户 Skill 时 revision 必须为空。")
|
||||
operation_id = _operation_id()
|
||||
skill_id = f"user_skill_{UUID(operation_id).hex}"
|
||||
if replay := _replay(operation_id, skill_id, request, ""):
|
||||
return _public(replay, tools)
|
||||
now = time_ns() // 1_000_000
|
||||
data = UserSkillData(
|
||||
version=1,
|
||||
created_at_ms=now,
|
||||
updated_at_ms=now,
|
||||
**request.model_dump(exclude={"revision"}),
|
||||
)
|
||||
document = call(
|
||||
"user_skills.write",
|
||||
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
|
||||
expected="",
|
||||
operation_id=operation_id,
|
||||
)
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def update_user_skill(skill_id: str, request: UserSkillWriteRequest, tools) -> UserSkill:
|
||||
_validate_skill_id(skill_id)
|
||||
_validate_declarations(request)
|
||||
if not request.revision:
|
||||
raise ApiError(422, "USER_SKILL_REVISION_REQUIRED", "更新用户 Skill 需要当前 revision。")
|
||||
operation_id = _operation_id()
|
||||
if replay := _replay(operation_id, skill_id, request, request.revision):
|
||||
return _public(replay, tools)
|
||||
current = get_user_skill(skill_id, tools)
|
||||
data = UserSkillData(
|
||||
version=current.data.version + 1,
|
||||
created_at_ms=current.data.created_at_ms,
|
||||
updated_at_ms=max(time_ns() // 1_000_000, current.data.updated_at_ms),
|
||||
**request.model_dump(exclude={"revision"}),
|
||||
)
|
||||
try:
|
||||
document = call(
|
||||
"user_skills.write",
|
||||
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
|
||||
expected=request.revision,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
except ApiError as error:
|
||||
if error.code == "REVISION_CONFLICT":
|
||||
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
|
||||
raise
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def delete_user_skill(skill_id: str, revision: str) -> None:
|
||||
_validate_skill_id(skill_id)
|
||||
if len(revision) != 64 or any(char not in "0123456789abcdef" for char in revision):
|
||||
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "删除用户 Skill 需要当前 revision。")
|
||||
operation_id = _operation_id()
|
||||
if _replay(operation_id, skill_id, None, revision):
|
||||
return
|
||||
try:
|
||||
call("user_skills.delete", id=skill_id, expected=revision, operation_id=operation_id)
|
||||
except ApiError as error:
|
||||
if error.code == "REVISION_CONFLICT":
|
||||
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
|
||||
raise
|
||||
|
||||
|
||||
def build_agent_configuration(skill_id: str, provider_capabilities: list[ModelCapability], tools) -> AgentConfiguration:
|
||||
skill = get_user_skill(skill_id, tools)
|
||||
if skill.status != "ready":
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_NOT_READY",
|
||||
"用户 Skill 的工具或权限声明尚未满足。",
|
||||
{
|
||||
"skill_id": skill_id,
|
||||
"missing_dependencies": skill.missing_dependencies,
|
||||
"undeclared_permissions": skill.undeclared_permissions,
|
||||
},
|
||||
)
|
||||
missing = sorted(
|
||||
capability.value
|
||||
for capability in set(skill.data.required_capabilities) - set(provider_capabilities)
|
||||
)
|
||||
if missing:
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_MODEL_CAPABILITY_MISSING",
|
||||
"当前模型不满足用户 Skill 的能力要求。",
|
||||
{"skill_id": skill_id, "missing_capabilities": missing},
|
||||
)
|
||||
return AgentConfiguration(
|
||||
skill_id=skill_id,
|
||||
system_prompt=skill.data.prompt,
|
||||
allowed_tools=list(skill.data.tools),
|
||||
permissions=list(skill.data.permissions),
|
||||
retrieval=skill.data.retrieval.model_copy(deep=True),
|
||||
)
|
||||
+16
-3
@@ -18,6 +18,9 @@ import threading
|
||||
|
||||
PROTOCOL = 1
|
||||
MAX_BOOTSTRAP = 16384
|
||||
UUID_PATTERN = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
)
|
||||
|
||||
|
||||
def bootstrap(line: bytes) -> dict:
|
||||
@@ -79,9 +82,19 @@ class SessionAuth:
|
||||
return
|
||||
from app import host_bridge
|
||||
vault = single(b"x-opennexus-vault").decode("ascii", errors="replace")
|
||||
token = host_bridge.vault_id.set(vault if re.fullmatch(r"[0-9a-f-]{36}", vault) else None)
|
||||
operation = single(b"x-request-id").decode("ascii", errors="replace")
|
||||
operation_token = host_bridge.operation_id.set(operation if re.fullmatch(r"[0-9a-f-]{36}", operation) else None)
|
||||
token = host_bridge.vault_id.set(vault if UUID_PATTERN.fullmatch(vault) else None)
|
||||
# Mutating clients may retain a UUID across an ambiguous response. Other
|
||||
# endpoint-specific idempotency tokens remain available to the route but
|
||||
# do not enter the Host journal unless they are valid operation UUIDs.
|
||||
idempotency = single(b"idempotency-key").decode("ascii", errors="replace")
|
||||
operation = (
|
||||
idempotency
|
||||
if UUID_PATTERN.fullmatch(idempotency)
|
||||
else single(b"x-request-id").decode("ascii", errors="replace")
|
||||
)
|
||||
operation_token = host_bridge.operation_id.set(
|
||||
operation if UUID_PATTERN.fullmatch(operation) else None
|
||||
)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
|
||||
@@ -318,6 +318,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
"/api/agent/runs/{run_id}/events",
|
||||
"/api/agent/runs/{run_id}/trace",
|
||||
"/api/skills",
|
||||
"/api/user-skills",
|
||||
"/api/user-skills/{skill_id}",
|
||||
"/api/plugins",
|
||||
"/api/plugins/install",
|
||||
"/api/plugins/{plugin_id}/host",
|
||||
|
||||
@@ -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