feat(sync): 添加 Vault 所有的用户 Skill 记录

This commit is contained in:
2026-09-09 08:39:14 +08:00
parent 8d7d23a4ae
commit 8dfb7a5cff
22 changed files with 1479 additions and 28 deletions
+15 -5
View File
@@ -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}",
+77
View File
@@ -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
+6
View File
@@ -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(
+49
View File
@@ -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())
+212
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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",
+227
View File
@@ -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"