From b0783c93560e83d90fcce28dc9f6cb9a59d981b6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 08:39:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E6=B7=BB=E5=8A=A0=20Vault=20?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9A=84=E7=94=A8=E6=88=B7=20Skill=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/agent/runtime.py | 20 +- backend/app/contracts.py | 77 ++++++ backend/app/extensions/runtime.py | 6 + backend/app/routes.py | 49 ++++ backend/app/services/user_skills.py | 212 ++++++++++++++++ backend/app/sidecar.py | 19 +- backend/tests/test_api.py | 2 + backend/tests/test_user_skills.py | 227 ++++++++++++++++++ docs/contracts/Sync-v1契约.md | 13 +- docs/contracts/后端接口契约-开发版.md | 5 + .../OpenNexus生产化实施进度-2026-09-08.md | 11 + frontend/src-tauri/src/preference_records.rs | 118 ++++++++- frontend/src-tauri/src/records.rs | 58 ++++- frontend/src-tauri/src/workspace.rs | 20 +- frontend/src-tauri/src/workspace_broker.rs | 99 ++++++++ frontend/src-tauri/tests/core_workspace.rs | 146 ++++++++++- frontend/src-tauri/tests/sync_push.rs | 26 +- frontend/src/contracts/index.ts | 35 +++ frontend/src/features/agent/AgentView.vue | 2 +- frontend/src/features/skills/SkillsView.vue | 2 + .../features/skills/UserSkillEditor.spec.ts | 97 ++++++++ .../src/features/skills/UserSkillEditor.vue | 168 +++++++++++++ frontend/src/services/skillService.spec.ts | 42 ++++ frontend/src/services/skillService.ts | 26 +- frontend/src/stores/skill.ts | 56 ++++- 25 files changed, 1507 insertions(+), 29 deletions(-) create mode 100644 backend/app/services/user_skills.py create mode 100644 backend/tests/test_user_skills.py create mode 100644 frontend/src/features/skills/UserSkillEditor.spec.ts create mode 100644 frontend/src/features/skills/UserSkillEditor.vue create mode 100644 frontend/src/services/skillService.spec.ts diff --git a/backend/app/agent/runtime.py b/backend/app/agent/runtime.py index 89174d6..6445f41 100644 --- a/backend/app/agent/runtime.py +++ b/backend/app/agent/runtime.py @@ -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}", diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 3d55b24..220a7e6 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index f346550..1147665 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -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( diff --git a/backend/app/routes.py b/backend/app/routes.py index d1a189e..f19da0f 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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()) diff --git a/backend/app/services/user_skills.py b/backend/app/services/user_skills.py new file mode 100644 index 0000000..b68c098 --- /dev/null +++ b/backend/app/services/user_skills.py @@ -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), + ) diff --git a/backend/app/sidecar.py b/backend/app/sidecar.py index 6431f72..3afecc6 100644 --- a/backend/app/sidecar.py +++ b/backend/app/sidecar.py @@ -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: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ed4327f..62b07ee 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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", diff --git a/backend/tests/test_user_skills.py b/backend/tests/test_user_skills.py new file mode 100644 index 0000000..c945daa --- /dev/null +++ b/backend/tests/test_user_skills.py @@ -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" diff --git a/docs/contracts/Sync-v1契约.md b/docs/contracts/Sync-v1契约.md index 51af58e..5658729 100644 --- a/docs/contracts/Sync-v1契约.md +++ b/docs/contracts/Sync-v1契约.md @@ -63,7 +63,7 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字 桌面 Task CRUD 经 Host records broker,操作重放返回原始记录且同 ID 不同字段拒绝;Core 不以全局 SQLite 作为任务来源。已明确归属于当前 Vault 的旧 Task 表在首次访问时逐条迁移,完成后设置所有权标记,来源表保留;未分配 Vault 的全局旧数据不猜测归属。文件身份采纳时,任务链接通过 Host 别名解析,并在上传队列物化前将规范化引用写成新的逻辑记录。 -用户 Skill/配置、可选对话等仍须逐类定义白名单与适配器;不得用复制任意 JSON/SQLite 代替。 +用户 Skill 的独立白名单与适配器见下文;可选对话等其他类别仍须逐类定义,不得用复制任意 JSON/SQLite 代替。 ## 主题与编辑器偏好记录 v1 @@ -82,6 +82,17 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字 本机已有三项侧栏 localStorage 值仍作为初始偏好读取并保存,不删除来源。组件共享布局状态,远端值立即反映在侧栏;窗口可用空间不足时仅收窄渲染宽度,不改写同步偏好,恢复空间后恢复偏好宽度。布局记录不含本机窗口坐标、显示器信息、已打开文件路径或执行权限。本版本未支持的旧客户端不能被视作已验证兼容,跨版本发布兼容验收仍需覆盖新增记录类别。 +## 用户 Skill 记录 v1 + +用户创建的声明式 Skill 使用 `opennexus-records/v1/user-skills/user_skill_<32位小写十六进制>.json`,kind 为 `user_skill`,业务 ID 与文件 `file_id` 分离;`user_skill_` 前缀由此命名空间保留,安装包不得占用。data 白名单为 version、name、description、prompt、tools、permissions、retrieval、required_capabilities、created_at_ms、updated_at_ms;记录信封和内容摘要继续作为文件 CAS 与冲突依据。用户 Skill 默认参与当前 Vault 同步。 + +name 为 1–128 个 Unicode 字符且不能全为空白,description 最多 2000 字符,prompt 最多 64000 字符。tools 最多 64 个、permissions 最多 32 个、required_capabilities 最多 16 个;各列表不得重复,标识仅允许 ASCII 字母、数字、点、下划线和连字符。retrieval 只含 top_k 1–100、rerank、citation;模型能力和权限采用当前公开枚举。时间为非负 UTC Unix 毫秒且 updated_at_ms 不早于 created_at_ms,version 不超过 JavaScript 安全整数。整体记录仍受 1 MiB 上限约束。 + +同步的 permissions 是 Skill 对工具需求的声明,不是目标设备授权。API Key、令牌、设备授权、启用状态、正在运行的 Agent、安装目录、包来源、环境变量和秘密值均不进入记录;新设备按本机 Tool 可用性、权限策略和模型能力重新判断。缺少工具或声明不足的记录可编辑和同步,但不可选择运行;用户明确选择一个 ready 的 Skill 后,每次工具调用仍经过设备本地 PermissionManager。 + +Core 经 Host 提供按 Vault 的 list/get/create/update/delete,创建 ID 从操作 UUID 稳定派生。相同操作 UUID 和相同载荷返回 Host 持久化的原回执;相同 UUID 配合不同 ID、CAS 基线、操作类型或字段值返回 `USER_SKILL_OPERATION_CONFLICT`。更新和删除必须携带 64 位内容 revision;过期 revision 返回 `USER_SKILL_REVISION_CONFLICT`。前端切换 Vault 后丢弃迟到响应,不能把旧 Vault 的列表或保存结果发布到新 Vault。 + + ## 工作区人设记录 v1 桌面端使用 `opennexus-records/v1/persona/default.json`,schema=1、kind=persona、id=default。data 白名单为 version(0–2^53−1 整数)、name(最多 128 Unicode 标量)、system_prompt(最多 16000 Unicode 标量)、dialogue_pairs(最多 20 对,每对仅 user/assistant,各最多 8000 Unicode 标量);整条记录仍受 1 MiB 字节限制。人设正文是用户内容,不自动赋予权限或启动任务。未知字段一律拒绝。 diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index 209d313..ba20085 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -72,6 +72,11 @@ Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault, | POST | `/api/skills/{skill_id}/enable` | 启用 Skill | | POST | `/api/skills/{skill_id}/disable` | 停用 Skill | | DELETE | `/api/skills/{skill_id}` | 卸载 Skill | +| GET | `/api/user-skills` | 列出当前 Vault 的用户 Skill 逻辑记录 | +| POST | `/api/user-skills` | 新建用户 Skill;支持 UUID Idempotency-Key | +| GET | `/api/user-skills/{skill_id}` | 读取用户 Skill 与内容 revision | +| PUT | `/api/user-skills/{skill_id}` | 以内容 revision CAS 更新用户 Skill | +| DELETE | `/api/user-skills/{skill_id}?revision=...` | 以内容 revision CAS 删除用户 Skill | | GET | `/api/plugins` | 获取 Plugin 列表及状态 | | POST | `/api/plugins/install` | 安装 Plugin | | GET | `/api/plugins/{plugin_id}` | 获取 Plugin Manifest 与状态 | diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 70ffce7..4cb5903 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -761,3 +761,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 修复同时解除首次上传/合并 seedCurrentPreferences 中准备布局记录时的 RECORD_SCOPE_DENIED 障碍;可选范围过滤仍由独立 sync_scope 决定,允许本机编辑不等于授权上传。尚未补齐用户 Skill 编辑/逻辑记录,本轮优先修复实际边界缺陷。 - 完整 desktop/all-targets Rust 回归 146 通过、12 ignored(library 129、Host 10、其他集成 7),包括真实 Core 桥接与 Sync 服务集成;Clippy --all-targets -D warnings 通过。日志 .build/layout-command-full.log 与 .build/layout-command-clippy.log。未执行真正双机桌面 UI 验收,不能据此标记整体生产化完成。 + + +## 增量:当前 Vault 的用户 Skill 逻辑记录 + +- 新增 `user_skill_<32位小写十六进制>` 严格逻辑记录,Host 固定存入 `opennexus-records/v1/user-skills/`;名称、提示词、工具、权限声明、模型能力、检索参数、版本与时间均有类型、数量和长度上限,未知字段在写入 journal 前拒绝。该类别进入默认同步范围;安装目录、密钥、设备授权、启用状态和运行环境不能写入记录,安装包也不能占用 `user_skill_` 保留命名空间。 +- Core 增加当前 Vault 专用的 list/get/create/update/delete API,并通过窄化 Host RPC 访问,不能借操作回执读取 persona/task 等记录。更新和删除使用摘要 CAS;有效 UUID `Idempotency-Key` 进入 Host journal,同一操作与相同输入可在响应未知后重放,改变目标、旧摘要、操作类型或业务字段会返回 `USER_SKILL_OPERATION_CONFLICT`。Workspace 持久回执补充原始 expected 摘要,重开后仍能完成输入一致性核验。 +- Skill 页面增加用户 Skill 编辑器,明确权限字段只是声明而非设备授权;工具缺失、权限声明不足与可运行状态分开展示。Agent 页面只列可运行记录,实际 `/api/agent/runs` 会从 Host 读取当前 Vault 的用户 Skill 并再次核对本机工具、权限声明和模型能力。列表按 Host 分页读取,避免超过 100 项时静默截断。 +- 前端保存和删除在结果不明确时保留原 UUID;同一 Vault、记录和表单内容重试会复用该 UUID,内容改变才产生新操作。Vault 切换同步清除编辑状态和待重试标识,迟到的列表或保存结果不得写入新 Vault 的界面状态。该内存重试状态不跨应用完全退出,不能将其视作跨重启 UI 草稿恢复。 +- 实际 Python Core + Rust Host 集成覆盖创建、20 次相同重放、改变输入冲突、列表、跨 Vault 拒绝、CAS 更新、过期摘要拒绝、Agent 选择运行、删除及关闭前 journal 计数。实际 Uvicorn Sync + 两个 Rust Workspace 客户端对用户 Skill 进行了 60 轮同时修改,保留本地/采用远端/创建文本副本各 20 轮,并验证旧摘要拒绝、重复解决、两端收敛、重开稳定;第三个默认范围客户端可接收用户 Skill,同时仍排除未选择的人设与布局。 +- 最终 desktop Rust 全目标 148 通过、12 ignored(library 131、Host 10、其余集成 7),Clippy `--all-targets -D warnings` 通过,日志 `.build/user-skill-rust-full-final.log`、`.build/user-skill-clippy-current.log`。后端 908 项通过、1 项依赖弃用警告,日志 `.build/user-skill-python-full-current.log`。前端 103 个测试文件、536 项通过,类型检查和生产构建通过,日志 `.build/user-skill-frontend-full-final2.log`、`.build/user-skill-frontend-build-final2.log`。 +- 证据来自本机隔离服务和客户端进程,尚未替代两台独立硬件上的桌面 UI、跨发布版本或每个持久化边界强制终止验收。Provider 非秘密参数、安装清单、对话等规划内逻辑数据仍未适配;完整生产化目标保持未完成。 diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs index c7cec64..7627a1b 100644 --- a/frontend/src-tauri/src/preference_records.rs +++ b/frontend/src-tauri/src/preference_records.rs @@ -1,4 +1,4 @@ -//! Preference schemas contain portable values only; no paths, permissions, providers or secrets. +//! Portable settings may declare required permissions, but never carry device grants, paths or secrets. use crate::workspace::{HostError, Result}; use serde::Deserialize; use serde_json::Value; @@ -17,6 +17,27 @@ struct Persona { dialogue_pairs: Vec, } #[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UserSkillRetrieval { + top_k: u8, + rerank: bool, + citation: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UserSkill { + version: u64, + name: String, + description: String, + prompt: String, + tools: Vec, + permissions: Vec, + retrieval: UserSkillRetrieval, + required_capabilities: Vec, + created_at_ms: i64, + updated_at_ms: i64, +} +#[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct Layout { primary_expanded: bool, @@ -103,6 +124,20 @@ fn markdown(value: &Markdown) -> bool { .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) } +fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool { + values.len() <= max_items + && values.iter().all(|value| { + !value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }) + && values + .iter() + .enumerate() + .all(|(index, value)| !values[..index].contains(value)) +} pub fn validate(kind: &str, value: &Value) -> Result<()> { let valid = match kind { "persona" => { @@ -121,6 +156,56 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> { (200.0..=520.0).contains(&value.workspace_width) && (200.0..=520.0).contains(&value.chat_width) } + "user_skill" => { + let value: UserSkill = decode(value)?; + let _ = (value.retrieval.rerank, value.retrieval.citation); + let timestamp = |time: i64| (0..=253402300799999).contains(&time); + let known_permissions = [ + "notes.read", + "notes.search", + "notes.write", + "notes.delete", + "tasks.read", + "tasks.write", + "attachments.read", + "network.request", + "secrets.use", + "ui.command", + "ui.settings", + "ui.sidebar", + ]; + let known_capabilities = [ + "chat", + "vision", + "tool_calling", + "reasoning", + "streaming", + "structured_output", + "embedding", + "transcription", + "speaker_matching", + ]; + value.version <= 9007199254740991 + && !value.name.trim().is_empty() + && value.name.chars().count() <= 128 + && value.description.chars().count() <= 2000 + && value.prompt.chars().count() <= 64000 + && unique_bounded(&value.tools, 64, 128) + && unique_bounded(&value.permissions, 32, 64) + && value + .permissions + .iter() + .all(|v| known_permissions.contains(&v.as_str())) + && unique_bounded(&value.required_capabilities, 16, 64) + && value + .required_capabilities + .iter() + .all(|v| known_capabilities.contains(&v.as_str())) + && (1..=100).contains(&value.retrieval.top_k) + && timestamp(value.created_at_ms) + && timestamp(value.updated_at_ms) + && value.updated_at_ms >= value.created_at_ms + } "theme_settings" => { let value: Theme = decode(value)?; let _ = value.headings.custom; @@ -181,6 +266,37 @@ mod tests { use super::*; use serde_json::json; #[test] + fn user_skill_schema_is_portable_strict_and_default_synced() { + let id = "user_skill_00000000000000000000000000000001"; + let path = crate::records::path_for("user_skill", id).unwrap(); + let data = json!({"version":1,"name":"Review","description":"Check a note","prompt":"Be precise.","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat","tool_calling"],"created_at_ms":1,"updated_at_ms":2}); + let record = json!({"schema":1,"kind":"user_skill","id":id,"data":data}); + crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap(); + assert!(crate::records::allowed(&path)); + assert!(crate::sync_scope::OptionalScope::default().includes(&path)); + for field in [ + "api_key", + "package_path", + "enabled", + "device_grants", + "environment", + ] { + let mut bad = data.clone(); + bad[field] = json!("private"); + assert_eq!( + validate("user_skill", &bad).unwrap_err().code, + "RECORD_SCHEMA_INVALID" + ); + } + let mut bad = data.clone(); + bad["permissions"] = json!(["notes.read", "unknown.permission"]); + assert_eq!( + validate("user_skill", &bad).unwrap_err().code, + "RECORD_DATA_INVALID" + ); + assert!(crate::records::path_for("user_skill", "user_skill_ABCD").is_err()); + } + #[test] fn layout_schema_limits_widths_and_rejects_device_fields() { let good = json!({"primaryExpanded":true,"workspaceWidth":400.5,"chatWidth":320}); let path = crate::records::path_for("layout", "sidebars").unwrap(); diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index b7e73bd..e1f4e64 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -1,5 +1,6 @@ //! Versioned logical records: explicit fields only, never raw application databases/config. use crate::workspace::{HostError, Result, Workspace}; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; #[derive(Serialize, Deserialize)] @@ -32,6 +33,18 @@ pub fn path(id: &str) -> Result { } Ok(format!("opennexus-records/v1/tasks/{id}.json")) } + +pub fn user_skill_path(id: &str) -> Result { + if !id.starts_with("user_skill_") + || id.len() != 43 + || !id[11..] + .bytes() + .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) + { + return Err(HostError::new("RECORD_ID_INVALID")); + } + Ok(format!("opennexus-records/v1/user-skills/{id}.json")) +} pub fn path_for(kind: &str, id: &str) -> Result { match (kind, id) { ("task", id) => path(id), @@ -41,6 +54,7 @@ pub fn path_for(kind: &str, id: &str) -> Result { ("persona", "default") => Ok("opennexus-records/v1/persona/default.json".into()), ("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()), ("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()), + ("user_skill", id) => user_skill_path(id), _ => Err(HostError::new("RECORD_ID_INVALID")), } } @@ -57,10 +71,17 @@ pub fn allowed(path_value: &str) -> bool { ) { return true; } - path_value + if path_value .strip_prefix("opennexus-records/v1/tasks/") .and_then(|v| v.strip_suffix(".json")) .is_some_and(|id| path(id).is_ok()) + { + return true; + } + path_value + .strip_prefix("opennexus-records/v1/user-skills/") + .and_then(|v| v.strip_suffix(".json")) + .is_some_and(|id| user_skill_path(id).is_ok()) } pub fn validate(path_value: &str, content: &[u8]) -> Result { if content.len() > 1024 * 1024 { @@ -154,23 +175,31 @@ impl Workspace { )) } pub fn record_list(&mut self, offset: usize, limit: usize) -> Result { + self.record_list_kind("task", offset, limit) + } + pub fn record_list_kind(&mut self, kind: &str, offset: usize, limit: usize) -> Result { if limit == 0 || limit > 1000 { return Err(HostError::new("RECORD_LIMIT_INVALID")); } + let prefix = match kind { + "task" => "opennexus-records/v1/tasks/", + "user_skill" => "opennexus-records/v1/user-skills/", + _ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")), + }; let paths = self .sync_paths()? .into_iter() - .filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path)) + .filter(|path| path.starts_with(prefix) && allowed(path)) .collect::>(); let total = paths.len(); let mut items = Vec::new(); let mut bytes = 0; for path in paths.into_iter().skip(offset).take(limit) { let id = path - .strip_prefix("opennexus-records/v1/tasks/") + .strip_prefix(prefix) .and_then(|v| v.strip_suffix(".json")) .ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?; - if let Some(value) = self.record_get(id)? { + if let Some(value) = self.record_get_kind(kind, id)? { let size = serde_json::to_vec(&value) .map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))? .len(); @@ -196,8 +225,27 @@ impl Workspace { } let bytes = self.payload(operation, &[])?; let record = validate(path, &bytes)?; + let expected = receipt["result"]["expected"] + .as_str() + .map(str::to_owned) + .or(self + .db + .query_row( + "SELECT expected FROM journal WHERE operation_id=?1", + [operation], + |row| row.get::<_, String>(0), + ) + .optional()?) + .or(self + .db + .query_row( + "SELECT hash FROM file_ops WHERE id=?1", + [operation], + |row| row.get::<_, String>(0), + ) + .optional()?); Ok(Some( - json!({"record":record,"hash":crate::workspace::hash(&bytes),"deleted":receipt["result"]["deleted"],"state":receipt["state"]}), + json!({"record":record,"hash":crate::workspace::hash(&bytes),"expected":expected,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}), )) } } diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index 4fba7ef..cd72664 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -379,9 +379,13 @@ impl Workspace { .optional()?; value .map(|(state, result)| { - let result: Option = result + let result: Option = result .map(|value| { - serde_json::from_str(&value).map_err(|_| HostError::new("DATABASE_ERROR")) + let parsed: serde_json::Value = serde_json::from_str(&value) + .map_err(|_| HostError::new("DATABASE_ERROR"))?; + serde_json::from_value::(parsed.clone()) + .map_err(|_| HostError::new("DATABASE_ERROR"))?; + Ok::(parsed) }) .transpose()?; Ok(serde_json::json!({"operation_id":operation_id,"state":state,"result":result})) @@ -690,7 +694,11 @@ impl Workspace { }) }, )?; - let result = serde_json::to_string(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?; + let mut result = + serde_json::to_value(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?; + result["expected"] = serde_json::json!(expected); + let result = + serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?; tx.execute( "UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1", params![operation_id, result], @@ -1002,11 +1010,15 @@ impl Workspace { } else { result.deleted = true; } + let mut operation_result = + serde_json::to_value(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?; + operation_result["expected"] = serde_json::json!(expected); tx.execute( "UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1", params![ id, - serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))? + serde_json::to_string(&operation_result) + .map_err(|_| HostError::new("DATABASE_ERROR"))? ], )?; tx.commit()?; diff --git a/frontend/src-tauri/src/workspace_broker.rs b/frontend/src-tauri/src/workspace_broker.rs index c087c12..27abb98 100644 --- a/frontend/src-tauri/src/workspace_broker.rs +++ b/frontend/src-tauri/src/workspace_broker.rs @@ -103,6 +103,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { .map(|v| v.unwrap_or(Value::Null)) .map_err(|e| e.code) } + "workspace.user_skills.list" => { + let p: List = decode(params)?; + bound(ws, &p.vault_id)?; + ws.record_list_kind("user_skill", p.offset, p.limit) + .map_err(|e| e.code) + } + "workspace.user_skills.get" => { + let p: RecordRead = decode(params)?; + bound(ws, &p.vault_id)?; + ws.record_get_kind("user_skill", &p.id) + .map(|v| v.unwrap_or(Value::Null)) + .map_err(|e| e.code) + } + "workspace.user_skills.write" => { + let p: RecordWrite = decode(params)?; + bound(ws, &p.vault_id)?; + if p.record["kind"] != "user_skill" { + return Err("RECORD_SCHEMA_UNSUPPORTED".into()); + } + let path = + crate::records::path_for("user_skill", p.record["id"].as_str().unwrap_or("")) + .map_err(|e| e.code)?; + let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?; + ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id) + .map_err(|e| e.code)?; + ws.record_operation(&p.operation_id) + .map(|v| v.unwrap_or(Value::Null)) + .map_err(|e| e.code) + } + "workspace.user_skills.delete" => { + let p: RecordDelete = decode(params)?; + bound(ws, &p.vault_id)?; + let path = crate::records::user_skill_path(&p.id).map_err(|e| e.code)?; + ws.mutate_operation("delete", &path, "", &p.expected, &p.operation_id) + .map_err(|e| e.code)?; + ws.record_operation(&p.operation_id) + .map(|v| v.unwrap_or(Value::Null)) + .map_err(|e| e.code) + } + "workspace.user_skills.operation" => { + let p: Operation = decode(params)?; + bound(ws, &p.vault_id)?; + let value = ws.record_operation(&p.operation_id).map_err(|e| e.code)?; + if value + .as_ref() + .is_some_and(|receipt| receipt["record"]["kind"] != "user_skill") + { + return Err("RECORD_OPERATION_DENIED".into()); + } + Ok(value.unwrap_or(Value::Null)) + } "workspace.records.get" => { let p: RecordRead = decode(params)?; bound(ws, &p.vault_id)?; @@ -234,6 +285,54 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { mod tests { use super::*; #[test] + fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let id = "user_skill_00000000000000000000000000000001"; + let record = json!({"schema":1,"kind":"user_skill","id":id,"data":{"version":1,"name":"Review","description":"","prompt":"Review carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":1}}); + let operation_id = uuid::Uuid::new_v4().to_string(); + let write = json!({"rpc":"workspace.user_skills.write","params":{"vault_id":ws.vault_id,"record":record,"expected":"","operation_id":operation_id}}); + let receipt = dispatch(&mut ws, &write).unwrap(); + assert_eq!(receipt["expected"], ""); + assert_eq!(receipt["deleted"], false); + assert_eq!(dispatch(&mut ws, &write).unwrap(), receipt); + let list = json!({"rpc":"workspace.user_skills.list","params":{"vault_id":ws.vault_id,"offset":0,"limit":100}}); + let listed = dispatch(&mut ws, &list).unwrap(); + assert_eq!(listed["total"], 1); + assert_eq!(listed["items"][0]["record"], record); + drop(ws); + let mut ws = Workspace::open(root.path()).unwrap(); + let delete = json!({"rpc":"workspace.user_skills.delete","params":{"vault_id":ws.vault_id,"id":id,"expected":receipt["hash"],"operation_id":uuid::Uuid::new_v4().to_string()}}); + let deleted = dispatch(&mut ws, &delete).unwrap(); + assert_eq!(deleted["deleted"], true); + assert_eq!(dispatch(&mut ws, &delete).unwrap(), deleted); + assert_eq!(dispatch(&mut ws, &list).unwrap()["total"], 0); + assert_eq!(ws.pending_count().unwrap(), 2); + let persona = json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":"private","system_prompt":"","dialogue_pairs":[]}}); + let persona_operation = uuid::Uuid::new_v4().to_string(); + ws.write_operation( + "opennexus-records/v1/persona/default.json", + "", + &serde_json::to_vec(&persona).unwrap(), + "local", + &persona_operation, + ) + .unwrap(); + let smuggle = json!({"rpc":"workspace.user_skills.operation","params":{"vault_id":ws.vault_id,"operation_id":persona_operation}}); + assert_eq!( + dispatch(&mut ws, &smuggle).unwrap_err(), + "RECORD_OPERATION_DENIED" + ); + let mut denied = write; + denied["params"]["vault_id"] = json!("other-vault"); + denied["params"]["operation_id"] = json!(uuid::Uuid::new_v4().to_string()); + assert_eq!( + dispatch(&mut ws, &denied).unwrap_err(), + "VAULT_PERMISSION_CHANGED" + ); + assert_eq!(ws.pending_count().unwrap(), 3); + } + #[test] fn persona_is_vault_bound_durable_and_cas_protected() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); diff --git a/frontend/src-tauri/tests/core_workspace.rs b/frontend/src-tauri/tests/core_workspace.rs index f0a0ce2..c6bb115 100644 --- a/frontend/src-tauri/tests/core_workspace.rs +++ b/frontend/src-tauri/tests/core_workspace.rs @@ -368,5 +368,149 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits() .unwrap() .unwrap(); assert_eq!(stored["hash"], first["revision"]); - assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 9); + + // User-created Skills are Vault records, use Host CAS/idempotency, and are + // resolved by the actual Agent route without copying package paths or grants. + let create_skill_operation = uuid::Uuid::new_v4(); + let skill_body = json!({ + "revision":"", "name":"Vault reviewer", "description":"portable", + "prompt":"Answer with the exact phrase user-skill-active.", "tools":[], + "permissions":[], "retrieval":{"top_k":10,"rerank":true,"citation":true}, + "required_capabilities":["chat"] + }); + let (status, user_skill) = request( + &mut core, + "POST", + "/api/user-skills", + &vault, + &create_skill_operation.to_string(), + Some(skill_body.clone()), + ) + .await; + assert_eq!(status, 201, "{user_skill}"); + let skill_id = user_skill["skill_id"].as_str().unwrap(); + assert_eq!( + skill_id, + format!("user_skill_{}", create_skill_operation.simple()) + ); + assert_eq!(user_skill["status"], "ready"); + for _ in 0..20 { + let (status, replay) = request( + &mut core, + "POST", + "/api/user-skills", + &vault, + &create_skill_operation.to_string(), + Some(skill_body.clone()), + ) + .await; + assert_eq!(status, 201, "{replay}"); + assert_eq!(replay, user_skill); + } + let mut changed = skill_body.clone(); + changed["name"] = json!("Changed replay"); + let (status, conflict) = request( + &mut core, + "POST", + "/api/user-skills", + &vault, + &create_skill_operation.to_string(), + Some(changed), + ) + .await; + assert_eq!(status, 409, "{conflict}"); + assert_eq!(conflict["error"]["code"], "USER_SKILL_OPERATION_CONFLICT"); + let (status, listed) = request( + &mut core, + "GET", + "/api/user-skills?limit=100&offset=0", + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{listed}"); + assert_eq!(listed["items"][0]["skill_id"], skill_id); + let (status, denied) = request( + &mut core, + "GET", + "/api/user-skills?limit=100&offset=0", + &uuid::Uuid::new_v4().to_string(), + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 409, "{denied}"); + assert_eq!(denied["error"]["code"], "VAULT_PERMISSION_CHANGED"); + let mut update_body = skill_body.clone(); + update_body["revision"] = user_skill["revision"].clone(); + update_body["name"] = json!("Updated reviewer"); + let update_operation = uuid::Uuid::new_v4().to_string(); + let (status, updated_skill) = request( + &mut core, + "PUT", + &format!("/api/user-skills/{skill_id}"), + &vault, + &update_operation, + Some(update_body.clone()), + ) + .await; + assert_eq!(status, 200, "{updated_skill}"); + assert_eq!(updated_skill["data"]["version"], 2); + let (status, stale) = request( + &mut core, + "PUT", + &format!("/api/user-skills/{skill_id}"), + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(update_body.clone()), + ) + .await; + assert_eq!(status, 409, "{stale}"); + assert_eq!(stale["error"]["code"], "USER_SKILL_REVISION_CONFLICT"); + for _ in 0..2 { + let (status, replay) = request( + &mut core, + "PUT", + &format!("/api/user-skills/{skill_id}"), + &vault, + &update_operation, + Some(update_body.clone()), + ) + .await; + assert_eq!(status, 200, "{replay}"); + assert_eq!(replay, updated_skill); + } + let (status, run) = request( + &mut core, + "POST", + "/api/agent/runs", + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(json!({"input":"Confirm the Skill configuration","provider_id":"mock","model":"mock-1","skill_id":skill_id})), + ) + .await; + assert_eq!(status, 202, "{run}"); + assert_eq!(run["skill_id"], skill_id); + let delete_operation = uuid::Uuid::new_v4().to_string(); + let delete_path = format!( + "/api/user-skills/{skill_id}?revision={}", + updated_skill["revision"].as_str().unwrap() + ); + for _ in 0..2 { + let (status, deleted) = request( + &mut core, + "DELETE", + &delete_path, + &vault, + &delete_operation, + None, + ) + .await; + assert_eq!(status, 200, "{deleted}"); + } + assert!(!root + .join(format!("opennexus-records/v1/user-skills/{skill_id}.json")) + .exists()); + assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 12); } diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index 7ed5964..a1c7e22 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -566,6 +566,12 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate json!({"primaryExpanded":true,"workspaceWidth":272,"chatWidth":320}), "workspaceWidth", ), + ( + "user_skill", + "user_skill_00000000000000000000000000000001", + json!({"version":1,"name":"initial","description":"portable","prompt":"Review carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":1}), + "name", + ), ] { let path = notesagent_host::records::path_for(kind, id).unwrap(); let record = json!({"schema":1,"kind":kind,"id":id,"data":data}); @@ -582,7 +588,7 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate let mut ws = target.lock().unwrap(); let current = ws.record_get_kind(kind, id).unwrap().unwrap(); let mut next = current["record"].clone(); - next["data"][field] = if kind == "persona" { + next["data"][field] = if kind != "layout" { json!(format!("side-{side}-round-{round}")) } else { json!(300 + round * 2 + side) @@ -665,7 +671,11 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate 0 ); assert!(!client_b.push_one(&workspace_b, &binding_b).await.unwrap()); - for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { + for (kind, id) in [ + ("persona", "default"), + ("layout", "sidebars"), + ("user_skill", "user_skill_00000000000000000000000000000001"), + ] { assert_eq!( workspace.lock().unwrap().record_get_kind(kind, id).unwrap(), workspace_b @@ -691,6 +701,18 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap() > 0 {} + assert_eq!( + excluded_ws + .lock() + .unwrap() + .record_get_kind("user_skill", "user_skill_00000000000000000000000000000001") + .unwrap(), + workspace + .lock() + .unwrap() + .record_get_kind("user_skill", "user_skill_00000000000000000000000000000001") + .unwrap() + ); for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { let original = workspace .lock() diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 2eaa4f7..25313bf 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -253,6 +253,41 @@ export interface Skill { enabled: boolean } +export type UserSkillStatus = 'ready' | 'dependency_missing' | 'permission_required' + +export interface UserSkillData { + version: number + name: string + description: string + prompt: string + tools: string[] + permissions: string[] + retrieval: { top_k: number; rerank: boolean; citation: boolean } + required_capabilities: string[] + created_at_ms: number + updated_at_ms: number +} + +export interface UserSkill { + skill_id: string + revision: string + data: UserSkillData + status: UserSkillStatus + missing_dependencies: string[] + undeclared_permissions: string[] +} + +export interface UserSkillWriteRequest { + revision: string + name: string + description: string + prompt: string + tools: string[] + permissions: string[] + retrieval: { top_k: number; rerank: boolean; citation: boolean } + required_capabilities: string[] +} + // ============ Plugin ============ export type PluginStatus = diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index 798d1d5..6561187 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -97,7 +97,7 @@ async function handleOpenCitation(data: Record) {
-
+
diff --git a/frontend/src/features/skills/SkillsView.vue b/frontend/src/features/skills/SkillsView.vue index 83bda4b..bf62170 100644 --- a/frontend/src/features/skills/SkillsView.vue +++ b/frontend/src/features/skills/SkillsView.vue @@ -9,6 +9,7 @@ import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.v import { onMounted, ref } from 'vue' import { useSkillStore } from '@/stores/skill' import { t } from '@/i18n' +import UserSkillEditor from './UserSkillEditor.vue' const skillStore = useSkillStore() const actionError = ref('') @@ -31,6 +32,7 @@ async function uninstall(skillId: string, name: string) {

{{ t('Skill 管理', 'Skill Management') }}

{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}

+
{{ skillStore.error || actionError }}
{{ skillStore.selectedSkill.status }}

{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}

v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || t('未知作者', 'Unknown author') }}

diff --git a/frontend/src/features/skills/UserSkillEditor.spec.ts b/frontend/src/features/skills/UserSkillEditor.spec.ts new file mode 100644 index 0000000..24be1b0 --- /dev/null +++ b/frontend/src/features/skills/UserSkillEditor.spec.ts @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import UserSkillEditor from './UserSkillEditor.vue' +import { useWorkspaceStore } from '@/stores/workspace' +import { useSkillStore } from '@/stores/skill' +import * as service from '@/services/skillService' +import type { UserSkill } from '@/contracts' + +vi.mock('@/services/skillService', () => ({ + listSkills: vi.fn(), listUserSkills: vi.fn(), createUserSkill: vi.fn(), updateUserSkill: vi.fn(), deleteUserSkill: vi.fn(), +})) + +const saved: UserSkill = { + skill_id: 'user_skill_' + '1'.repeat(32), revision: 'a'.repeat(64), status: 'ready', + missing_dependencies: [], undeclared_permissions: [], + data: { version: 1, name: 'Review', description: '', prompt: 'Review carefully', tools: ['notes.read'], permissions: ['notes.read'], retrieval: { top_k: 10, rerank: true, citation: true }, required_capabilities: ['chat'], created_at_ms: 1, updated_at_ms: 1 }, +} + +beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + useWorkspaceStore().vaultId = 'vault-one' + vi.mocked(service.listSkills).mockResolvedValue([]) + vi.mocked(service.listUserSkills).mockResolvedValue([]) + vi.mocked(service.createUserSkill).mockResolvedValue(saved) + vi.mocked(service.updateUserSkill).mockResolvedValue({ ...saved, revision: 'b'.repeat(64), data: { ...saved.data, version: 2 } }) + vi.mocked(service.deleteUserSkill).mockResolvedValue({ status: 'completed' }) +}) + +it('creates a complete declarative record and labels declarations as non-grants', async () => { + const wrapper = mount(UserSkillEditor) + await wrapper.findAll('input.input')[0]!.setValue('Review') + await wrapper.findAll('input.input')[1]!.setValue('notes.read') + await wrapper.get('textarea').setValue('Review carefully') + await wrapper.get('input[value="notes.read"]').setValue(true) + await wrapper.get('input[value="chat"]').setValue(true) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(service.createUserSkill).toHaveBeenCalledWith(expect.objectContaining({ + revision: '', name: 'Review', prompt: 'Review carefully', tools: ['notes.read'], + permissions: ['notes.read'], required_capabilities: ['chat'], + retrieval: { top_k: 10, rerank: true, citation: true }, + }), expect.stringMatching(/^[0-9a-f-]{36}$/)) + expect(wrapper.text()).toContain('不是设备授权') + expect(useSkillStore().userSkills).toHaveLength(1) + wrapper.unmount() +}) + +it('does not publish a late save response into a different Vault', async () => { + let finish!: (value: UserSkill) => void + vi.mocked(service.createUserSkill).mockImplementation(() => new Promise(resolve => { finish = resolve })) + const wrapper = mount(UserSkillEditor) + await wrapper.findAll('input.input')[0]!.setValue('Review') + await wrapper.get('form').trigger('submit') + useWorkspaceStore().vaultId = 'vault-two' + await wrapper.vm.$nextTick() + finish(saved) + await flushPromises() + expect(useSkillStore().userSkills).toEqual([]) + expect(wrapper.text()).toContain('WORKSPACE_CHANGED') + wrapper.unmount() +}) + +it('does not publish a late list response into a different Vault', async () => { + let finish!: (value: UserSkill[]) => void + vi.mocked(service.listUserSkills).mockImplementation(() => new Promise(resolve => { finish = resolve })) + const store = useSkillStore() + const loading = store.loadSkills() + useWorkspaceStore().vaultId = 'vault-two' + finish([saved]) + await loading + expect(store.userSkills).toEqual([]) +}) + +it('reuses the operation UUID after an ambiguous save failure and changes it with the payload', async () => { + vi.mocked(service.createUserSkill) + .mockRejectedValueOnce(new Error('HOST_TIMEOUT')) + .mockResolvedValueOnce(saved) + .mockRejectedValueOnce(new Error('HOST_TIMEOUT')) + const wrapper = mount(UserSkillEditor) + const name = wrapper.findAll('input.input')[0]! + await name.setValue('Review') + await wrapper.get('form').trigger('submit') + await flushPromises() + const firstOperation = vi.mocked(service.createUserSkill).mock.calls[0]![1] + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(vi.mocked(service.createUserSkill).mock.calls[1]![1]).toBe(firstOperation) + await wrapper.findAll('button').find(button => button.text().includes('清空表单'))!.trigger('click') + await name.setValue('Changed') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(vi.mocked(service.createUserSkill).mock.calls[2]![1]).not.toBe(firstOperation) + wrapper.unmount() +}) diff --git a/frontend/src/features/skills/UserSkillEditor.vue b/frontend/src/features/skills/UserSkillEditor.vue new file mode 100644 index 0000000..d9c596a --- /dev/null +++ b/frontend/src/features/skills/UserSkillEditor.vue @@ -0,0 +1,168 @@ + + +