diff --git a/README.md b/README.md index 10d735b..7026ca3 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 126 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 @@ -133,6 +133,7 @@ pnpm test | [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 | | [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core | | [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 | +| [Plugin Command 与 Settings](docs/development/Plugin-Command与Settings开发说明.md) | Command Registry、Settings Schema、Secret 引用与联调边界 | | [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 | | [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 | | [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 | diff --git a/backend/README.md b/backend/README.md index 3d4fde4..a087fa7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。 -当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama;OpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。 +当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、stdio MCP Host、Plugin Command/Settings、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama;OpenAI Responses、Anthropic Messages、操作系统级 Plugin 沙箱和真实语音模型仍属于后续阶段。 ```powershell uv sync @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 126 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index df90158..fb53ed2 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -11,6 +11,7 @@ from jsonschema import Draft202012Validator from jsonschema.exceptions import ValidationError as JsonSchemaValidationError from app.contracts import ToolCall, ToolDefinition, ToolResult +from app.schema_security import reject_external_schema_references ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]] @@ -54,6 +55,8 @@ class ToolRegistry: arguments_model: type[BaseModel], executor: ToolExecutor, ) -> None: + Draft202012Validator.check_schema(definition.parameters) + reject_external_schema_references(definition.parameters) with self._lock: if definition.name in self._tools: raise ValueError(f"Tool already registered: {definition.name}") diff --git a/backend/app/container.py b/backend/app/container.py index 9263ca9..62b21be 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -53,6 +53,7 @@ def build_container() -> ApplicationContainer: plugins = PluginRuntime( tools, + credentials=credentials, # 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到 # Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。 allow_unsandboxed_mcp=settings.environment == "development", diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 8d1aace..f95e890 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Literal +from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, SecretStr @@ -486,6 +486,172 @@ class PluginHostStatus(Contract): error: str | None = None +class PluginCommandLocation(str, Enum): + command_palette = "command_palette" + context_menu = "context_menu" + toolbar = "toolbar" + + +class PluginCommand(Contract): + command_id: str + plugin_id: str + title: str + description: str = "" + icon: str | None = None + locations: list[PluginCommandLocation] = Field(default_factory=list) + when: list[str] = Field(default_factory=list) + parameters: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + + +class PluginCommandListResponse(Contract): + items: list[PluginCommand] = Field(default_factory=list) + + +class PluginCommandContext(Contract): + vault_id: str | None = None + note_id: str | None = None + file_path: str | None = None + selection: str | None = None + + +class PluginCommandExecuteRequest(Contract): + arguments: dict[str, Any] = Field(default_factory=dict) + context: PluginCommandContext = Field(default_factory=PluginCommandContext) + + +class PluginNotificationEffectPayload(Contract): + level: Literal["info", "success", "warning", "error"] = "info" + message: str = Field(min_length=1, max_length=4096) + + +class PluginNavigateEffectPayload(Contract): + route: Literal[ + "vault-entry", + "workspace", + "search", + "chat", + "agent", + "tasks", + "skills", + "plugins", + "themes", + "settings", + ] + + +class PluginRefreshEffectPayload(Contract): + scope: Literal["workspace", "commands", "settings", "plugins"] + + +class PluginJobEffectPayload(Contract): + job_id: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) + + +class PluginNoEffectPayload(Contract): + pass + + +class PluginNoEffect(Contract): + type: Literal["none"] = "none" + payload: PluginNoEffectPayload = Field(default_factory=PluginNoEffectPayload) + + +class PluginNotificationEffect(Contract): + type: Literal["notification"] = "notification" + payload: PluginNotificationEffectPayload + + +class PluginNavigateEffect(Contract): + type: Literal["navigate"] = "navigate" + payload: PluginNavigateEffectPayload + + +class PluginRefreshEffect(Contract): + type: Literal["refresh"] = "refresh" + payload: PluginRefreshEffectPayload + + +class PluginJobEffect(Contract): + type: Literal["job"] = "job" + payload: PluginJobEffectPayload + + +PluginCommandEffect = Annotated[ + PluginNoEffect + | PluginNotificationEffect + | PluginNavigateEffect + | PluginRefreshEffect + | PluginJobEffect, + Field(discriminator="type"), +] + +PLUGIN_COMMAND_EFFECT_TYPES = ( + PluginNoEffect, + PluginNotificationEffect, + PluginNavigateEffect, + PluginRefreshEffect, + PluginJobEffect, +) + + +class PluginCommandResult(Contract): + command_id: str + status: Literal["completed"] = "completed" + effect: PluginCommandEffect = Field(default_factory=PluginNoEffect) + + +class PluginSettingType(str, Enum): + string = "string" + number = "number" + boolean = "boolean" + select = "select" + secret = "secret" + + +class PluginSettingField(Contract): + key: str + label: str + description: str = "" + type: PluginSettingType + required: bool = False + default: Any | None = None + minimum: float | None = None + maximum: float | None = None + options: list[str] = Field(default_factory=list) + + +class PluginSecretState(Contract): + configured: bool = False + + +class PluginSettingsSchema(Contract): + plugin_id: str + schema_version: int = Field(ge=1) + fields: list[PluginSettingField] = Field(default_factory=list) + values: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, PluginSecretState] = Field(default_factory=dict) + + +class PluginSettingsUpdateRequest(Contract): + schema_version: int = Field(ge=1) + values: dict[str, Any] = Field(default_factory=dict) + + +class PluginSecretWriteRequest(Contract): + secret: SecretStr + + +class PluginSecretStatus(Contract): + plugin_id: str + key: str + configured: bool + + class PluginPermissionGrantRequest(Contract): permissions: list[str] = Field(default_factory=list) diff --git a/backend/app/extensions/__init__.py b/backend/app/extensions/__init__.py index ed2e570..41c8599 100644 --- a/backend/app/extensions/__init__.py +++ b/backend/app/extensions/__init__.py @@ -1,9 +1,5 @@ -from app.extensions.runtime import ( - AgentConfiguration, - ExtensionError, - PluginRuntime, - SkillRuntime, -) +from app.extensions.errors import ExtensionError +from app.extensions.runtime import AgentConfiguration, PluginRuntime, SkillRuntime from app.extensions.mcp import McpBridge, McpBridgeError __all__ = [ diff --git a/backend/app/extensions/contributions.py b/backend/app/extensions/contributions.py new file mode 100644 index 0000000..5402f42 --- /dev/null +++ b/backend/app/extensions/contributions.py @@ -0,0 +1,847 @@ +"""Plugin Command Registry 与 Settings/Secret 命名空间存储。""" + +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import json +import math +import re +import threading +from collections import deque +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from time import perf_counter +from typing import Any, Awaitable, Callable, Literal + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.config import get_settings +from app.contracts import ( + PLUGIN_COMMAND_EFFECT_TYPES, + PluginCommand, + PluginCommandContext, + PluginCommandEffect, + PluginCommandLocation, + PluginCommandResult, + PluginSecretState, + PluginSecretStatus, + PluginSettingField, + PluginSettingType, + PluginSettingsSchema, +) +from app.extensions.errors import ExtensionError +from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore +from app.schema_security import ( + SchemaReferenceError, + reject_external_schema_references, +) + +_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$") +_HOST_ICONS = {"bolt", "document", "edit", "link", "refresh", "search", "setting"} +_WHEN_TOKENS = { + "workspace.has_vault", + "editor.has_note", + "editor.has_selection", +} +_CONTEXT_KEYS = {"vault_id", "note_id", "file_path", "selection"} +_WHEN_CONTEXT = { + "workspace.has_vault": "vault_id", + "editor.has_note": "note_id", + "editor.has_selection": "selection", +} + + +class PluginCommandSpec(BaseModel): + """包内 commands.yaml 的宿主侧声明,不直接暴露 handler。""" + + model_config = ConfigDict(extra="forbid") + + command_id: str + title: str + description: str = "" + icon: str | None = None + locations: list[PluginCommandLocation] = Field(default_factory=list) + when: list[str] = Field(default_factory=list) + context: list[Literal["vault_id", "note_id", "file_path", "selection"]] = Field( + default_factory=list + ) + parameters: dict[str, Any] = Field( + default_factory=lambda: { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + ) + permission: str | None = None + secrets: list[str] = Field(default_factory=list) + handler: Literal["echo", "uppercase_selection"] | None = None + mcp_tool: str | None = None + timeout_seconds: int = Field(default=30, ge=1, le=120) + + @model_validator(mode="after") + def validate_execution_target(self) -> "PluginCommandSpec": + if (self.handler is None) == (self.mcp_tool is None): + raise ValueError("Command must declare exactly one handler or mcp_tool target.") + return self + + +CommandExecutor = Callable[ + [dict[str, Any], dict[str, Any]], + PluginCommandEffect | Awaitable[PluginCommandEffect], +] +PluginSecretResolver = Callable[[str], str | None] + + +@dataclass(slots=True) +class _RegisteredCommand: + command: PluginCommand + spec: PluginCommandSpec + executor: CommandExecutor + + +@dataclass(frozen=True, slots=True) +class PluginCommandAuditEvent: + """不记录参数与上下文的轻量审计事件,避免把正文或 Secret 写入日志。""" + + command_id: str + plugin_id: str + status: Literal["completed", "failed"] + duration_ms: int + error_code: str | None + created_at: datetime + + +class CommandRegistry: + """只发布已启用 Plugin 的受控 Command Contribution。""" + + def __init__(self) -> None: + self._commands: dict[str, _RegisteredCommand] = {} + self._audit: deque[PluginCommandAuditEvent] = deque(maxlen=500) + self._lock = threading.RLock() + + def register( + self, + plugin_id: str, + spec: PluginCommandSpec, + executor: CommandExecutor, + ) -> None: + validate_command_spec(plugin_id, spec) + command = PluginCommand( + command_id=spec.command_id, + plugin_id=plugin_id, + title=spec.title, + description=spec.description, + icon=spec.icon, + locations=spec.locations, + when=spec.when, + parameters=spec.parameters, + enabled=True, + ) + with self._lock: + if spec.command_id in self._commands: + raise ExtensionError( + "PLUGIN_COMMAND_CONFLICT", + f"Plugin command is already registered: {spec.command_id}", + status_code=409, + details={"command_id": spec.command_id}, + ) + self._commands[spec.command_id] = _RegisteredCommand(command, spec, executor) + + def unregister(self, command_id: str) -> None: + with self._lock: + self._commands.pop(command_id, None) + + def contains(self, command_id: str) -> bool: + with self._lock: + return command_id in self._commands + + def list(self, location: PluginCommandLocation | None = None) -> list[PluginCommand]: + with self._lock: + items = [ + item.command.model_copy(deep=True) + for item in self._commands.values() + if location is None or location in item.command.locations + ] + return sorted(items, key=lambda item: item.command_id) + + def audit_events(self) -> list[PluginCommandAuditEvent]: + """返回有界审计快照;事件刻意不包含 arguments/context/effect。""" + + with self._lock: + return list(self._audit) + + async def execute( + self, + command_id: str, + arguments: dict[str, Any], + context: PluginCommandContext, + ) -> PluginCommandResult: + with self._lock: + registered = self._commands.get(command_id) + if registered is None: + raise ExtensionError( + "PLUGIN_COMMAND_NOT_FOUND", + f"Plugin command is not registered or enabled: {command_id}", + status_code=404, + details={"command_id": command_id}, + ) + started_at = perf_counter() + try: + Draft202012Validator(registered.spec.parameters).validate(arguments) + except JsonSchemaValidationError as exc: + error = ExtensionError( + "PLUGIN_COMMAND_ARGUMENT_INVALID", + "Plugin command arguments do not match the declared schema.", + details={"command_id": command_id, "path": list(exc.path)}, + ) + self._record_audit(registered, started_at, error.code) + raise error from exc + + raw_context = context.model_dump(exclude_none=True) + missing = [ + token + for token in registered.spec.when + if not raw_context.get(_WHEN_CONTEXT[token]) + ] + if missing: + error = ExtensionError( + "PLUGIN_COMMAND_CONTEXT_INVALID", + "Plugin command context does not satisfy its when conditions.", + details={"command_id": command_id, "missing": missing}, + ) + self._record_audit(registered, started_at, error.code) + raise error + scoped_context = { + key: raw_context[key] + for key in registered.spec.context + if key in raw_context + } + try: + effect = registered.executor(dict(arguments), scoped_context) + if inspect.isawaitable(effect): + effect = await asyncio.wait_for( + effect, timeout=registered.spec.timeout_seconds + ) + except TimeoutError as exc: + error = ExtensionError( + "PLUGIN_COMMAND_TIMEOUT", + "Plugin command execution timed out.", + status_code=504, + details={"command_id": command_id}, + ) + self._record_audit(registered, started_at, error.code) + raise error from exc + except ExtensionError as exc: + self._record_audit(registered, started_at, exc.code) + raise + except Exception as exc: + error = ExtensionError( + "PLUGIN_COMMAND_EXECUTION_FAILED", + "Plugin command execution failed.", + status_code=502, + details={"command_id": command_id}, + ) + self._record_audit(registered, started_at, error.code) + raise error from exc + if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES): + error = ExtensionError( + "PLUGIN_COMMAND_RESULT_INVALID", + "Plugin command returned an invalid effect.", + status_code=502, + details={"command_id": command_id}, + ) + self._record_audit(registered, started_at, error.code) + raise error + try: + encoded_effect = json.dumps(effect.model_dump(mode="json"), ensure_ascii=False) + except (TypeError, ValueError) as exc: + error = ExtensionError( + "PLUGIN_COMMAND_RESULT_INVALID", + "Plugin command returned a non-serializable effect.", + status_code=502, + details={"command_id": command_id}, + ) + self._record_audit(registered, started_at, error.code) + raise error from exc + if len(encoded_effect.encode("utf-8")) > 64 * 1024: + error = ExtensionError( + "PLUGIN_COMMAND_RESULT_TOO_LARGE", + "Plugin command effect exceeds the 64 KiB response limit.", + status_code=502, + details={"command_id": command_id}, + ) + self._record_audit(registered, started_at, error.code) + raise error + self._record_audit(registered, started_at, None) + return PluginCommandResult(command_id=command_id, effect=effect) + + def _record_audit( + self, + registered: _RegisteredCommand, + started_at: float, + error_code: str | None, + ) -> None: + event = PluginCommandAuditEvent( + command_id=registered.command.command_id, + plugin_id=registered.command.plugin_id, + status="failed" if error_code else "completed", + duration_ms=max(0, round((perf_counter() - started_at) * 1000)), + error_code=error_code, + created_at=datetime.now(UTC), + ) + with self._lock: + self._audit.append(event) + + +class PluginSettingsDefinition(BaseModel): + model_config = ConfigDict(extra="forbid") + + section_id: str + schema_version: int = Field(ge=1) + fields: list[PluginSettingField] = Field(default_factory=list) + + +class PluginSettingsStore: + """非敏感值写入插件命名空间;Secret 只保存加密凭据引用。""" + + def __init__(self, credentials: EncryptedCredentialStore) -> None: + self.credentials = credentials + self._lock = threading.RLock() + + @staticmethod + def _path() -> Path: + return get_settings().data_dir / "plugins" / "settings.json" + + def get( + self, plugin_id: str, definition: PluginSettingsDefinition + ) -> PluginSettingsSchema: + with self._lock: + entry = self._entry(self._read(), plugin_id) + stored_values = entry.get("values", {}) + secret_refs = entry.get("secret_refs", {}) + if not isinstance(stored_values, dict) or not isinstance(secret_refs, dict): + raise self._storage_format_error(plugin_id) + validated_refs = self._validate_secret_refs(plugin_id, secret_refs) + values = { + field.key: field.default + for field in definition.fields + if field.type != PluginSettingType.secret and field.default is not None + } + allowed_values = { + field.key + for field in definition.fields + if field.type != PluginSettingType.secret + } + fields = {field.key: field for field in definition.fields} + for key, value in stored_values.items(): + if key not in allowed_values: + continue + try: + _validate_setting_value(fields[key], value) + except ExtensionError as exc: + raise self._storage_format_error(plugin_id) from exc + values[key] = value + secrets: dict[str, PluginSecretState] = {} + for field in definition.fields: + if field.type != PluginSettingType.secret: + continue + reference = validated_refs.get(field.key) + secrets[field.key] = PluginSecretState( + configured=isinstance(reference, str) and self._has_secret(reference) + ) + return PluginSettingsSchema( + plugin_id=plugin_id, + schema_version=definition.schema_version, + fields=definition.fields, + values=values, + secrets=secrets, + ) + + def runtime_values( + self, plugin_id: str, definition: PluginSettingsDefinition + ) -> dict[str, Any]: + """返回可供 Command 使用的完整普通设置,并拦截未配置的必填项。""" + + schema = self.get(plugin_id, definition) + missing = [ + field.key + for field in definition.fields + if field.required + and field.type != PluginSettingType.secret + and field.key not in schema.values + ] + if missing: + raise ExtensionError( + "PLUGIN_SETTINGS_REQUIRED", + "Required Plugin settings have not been configured.", + status_code=409, + details={"plugin_id": plugin_id, "fields": missing}, + ) + return schema.values + + def update( + self, + plugin_id: str, + definition: PluginSettingsDefinition, + schema_version: int, + values: dict[str, Any], + ) -> PluginSettingsSchema: + if schema_version != definition.schema_version: + raise ExtensionError( + "PLUGIN_SETTINGS_VERSION_CONFLICT", + "Plugin settings schema version is out of date.", + status_code=409, + details={ + "plugin_id": plugin_id, + "requested_version": schema_version, + "current_version": definition.schema_version, + }, + ) + fields = {field.key: field for field in definition.fields} + unknown = sorted(set(values) - set(fields)) + if unknown: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + "Plugin settings contain unknown fields.", + details={"plugin_id": plugin_id, "fields": unknown}, + ) + secret_keys = sorted( + key for key in values if fields[key].type == PluginSettingType.secret + ) + if secret_keys: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + "Secret fields must use the dedicated Secret endpoint.", + details={"plugin_id": plugin_id, "fields": secret_keys}, + ) + for key, value in values.items(): + _validate_setting_value(fields[key], value) + + with self._lock: + data = self._read() + entry = self._entry(data, plugin_id, create=True) + current = entry.get("values", {}) + if not isinstance(current, dict): + raise self._storage_format_error(plugin_id) + entry["values"] = current + current.update(values) + effective = { + field.key: field.default + for field in definition.fields + if field.type != PluginSettingType.secret and field.default is not None + } + effective.update(current) + missing = [ + field.key + for field in definition.fields + if field.required + and field.type != PluginSettingType.secret + and field.key not in effective + ] + if missing: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + "Required Plugin settings are missing.", + details={"plugin_id": plugin_id, "fields": missing}, + ) + entry["schema_version"] = definition.schema_version + self._write(data) + return self.get(plugin_id, definition) + + def put_secret( + self, + plugin_id: str, + definition: PluginSettingsDefinition, + key: str, + secret: str, + ) -> PluginSecretStatus: + _secret_field(definition, plugin_id, key) + if not secret: + raise ExtensionError( + "PLUGIN_SECRET_VALUE_INVALID", + "Plugin secret cannot be empty.", + details={"plugin_id": plugin_id, "key": key}, + ) + if len(secret.encode("utf-8")) > 64 * 1024: + raise ExtensionError( + "PLUGIN_SECRET_VALUE_INVALID", + "Plugin secret exceeds the 64 KiB limit.", + details={"plugin_id": plugin_id, "key": key}, + ) + reference = _secret_reference(plugin_id, key) + with self._lock: + data = self._read() + entry = self._entry(data, plugin_id, create=True) + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + raise self._storage_format_error(plugin_id) + self._validate_secret_refs(plugin_id, refs) + entry["secret_refs"] = refs + try: + previous = self.credentials.resolve(reference) + self.credentials.put(reference, secret) + except CredentialStoreError as exc: + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + refs[key] = reference + entry["schema_version"] = definition.schema_version + try: + self._write(data) + except ExtensionError: + # 普通设置落盘失败时恢复凭据旧值,避免产生不可达的新 Secret。 + try: + if previous is None: + self.credentials.delete(reference) + else: + self.credentials.put(reference, previous) + except CredentialStoreError: + pass + raise + return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=True) + + def delete_secret( + self, + plugin_id: str, + definition: PluginSettingsDefinition, + key: str, + ) -> PluginSecretStatus: + _secret_field(definition, plugin_id, key) + with self._lock: + data = self._read() + entry = self._entry(data, plugin_id) + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + raise self._storage_format_error(plugin_id) + self._validate_secret_refs(plugin_id, refs) + reference = _secret_reference(plugin_id, key) + had_reference = refs.pop(key, None) is not None + if plugin_id in data and had_reference: + self._write(data) + try: + self.credentials.delete(reference) + except CredentialStoreError as exc: + if had_reference: + refs[key] = reference + try: + self._write(data) + except ExtensionError as rollback_exc: + raise ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin Secret deletion failed and its reference could not be restored.", + status_code=500, + details={"plugin_id": plugin_id, "key": key}, + ) from rollback_exc + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=False) + + def resolve_secret( + self, plugin_id: str, definition: PluginSettingsDefinition, key: str + ) -> str | None: + _secret_field(definition, plugin_id, key) + with self._lock: + entry = self._entry(self._read(), plugin_id) + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + raise self._storage_format_error(plugin_id) + reference = self._validate_secret_refs(plugin_id, refs).get(key) + try: + return self.credentials.resolve(reference) if isinstance(reference, str) else None + except CredentialStoreError as exc: + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + + def remove_plugin(self, plugin_id: str) -> None: + with self._lock: + data = self._read() + entry = data.pop(plugin_id, None) + references: list[str] = [] + if entry is not None and not isinstance(entry, dict): + raise self._storage_format_error(plugin_id) + if entry is not None: + refs = entry.get("secret_refs", {}) + if not isinstance(refs, dict): + raise self._storage_format_error(plugin_id) + references = list(self._validate_secret_refs(plugin_id, refs).values()) + if entry is not None: + self._write(data) + try: + self.credentials.delete_many(references) + except CredentialStoreError as exc: + if entry is not None: + data[plugin_id] = entry + try: + self._write(data) + except ExtensionError as rollback_exc: + raise ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin uninstall failed and its Settings namespace could not be restored.", + status_code=500, + details={"plugin_id": plugin_id}, + ) from rollback_exc + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + + def _validate_secret_refs( + self, plugin_id: str, refs: dict[Any, Any] + ) -> dict[str, str]: + validated: dict[str, str] = {} + for key, reference in refs.items(): + if ( + not isinstance(key, str) + or not _SETTING_KEY.fullmatch(key) + or not isinstance(reference, str) + or reference != _secret_reference(plugin_id, key) + ): + raise self._storage_format_error(plugin_id) + validated[key] = reference + return validated + + def _has_secret(self, reference: str) -> bool: + try: + return self.credentials.has(reference) + except CredentialStoreError as exc: + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + + @staticmethod + def _storage_format_error(plugin_id: str) -> ExtensionError: + return ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin settings namespace has an invalid format.", + status_code=500, + details={"plugin_id": plugin_id}, + ) + + def _entry( + self, + data: dict[str, dict[str, Any]], + plugin_id: str, + *, + create: bool = False, + ) -> dict[str, Any]: + entry = data.get(plugin_id) + if entry is None: + if create: + data[plugin_id] = {} + return data[plugin_id] + return {} + if not isinstance(entry, dict): + raise self._storage_format_error(plugin_id) + return entry + + def _read(self) -> dict[str, dict[str, Any]]: + path = self._path() + if not path.exists(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin settings storage cannot be loaded.", + status_code=500, + ) from exc + if not isinstance(value, dict): + raise ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin settings storage has an invalid format.", + status_code=500, + ) + return value + + def _write(self, value: dict[str, dict[str, Any]]) -> None: + path = self._path() + temporary = path.with_suffix(".tmp") + try: + path.parent.mkdir(parents=True, exist_ok=True) + temporary.write_text( + json.dumps(value, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + temporary.replace(path) + except OSError as exc: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise ExtensionError( + "PLUGIN_STORAGE_ERROR", + "Plugin settings storage cannot be written.", + status_code=500, + ) from exc + + +def validate_settings_definition( + plugin_id: str, definition: PluginSettingsDefinition +) -> None: + if not _CONTRIBUTION_ID.fullmatch(definition.section_id): + raise _settings_schema_error(plugin_id, "Settings section id is invalid.") + if not definition.section_id.startswith(f"{plugin_id}."): + raise _settings_schema_error( + plugin_id, "Settings section id must use the Plugin namespace." + ) + keys: set[str] = set() + for field in definition.fields: + if not _SETTING_KEY.fullmatch(field.key) or field.key in keys: + raise _settings_schema_error(plugin_id, f"Invalid or duplicate setting key: {field.key}") + keys.add(field.key) + if field.type == PluginSettingType.select and not field.options: + raise _settings_schema_error(plugin_id, f"Select setting requires options: {field.key}") + if field.type != PluginSettingType.select and field.options: + raise _settings_schema_error(plugin_id, f"Only select settings accept options: {field.key}") + if field.type != PluginSettingType.number and ( + field.minimum is not None or field.maximum is not None + ): + raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}") + if any( + bound is not None and not math.isfinite(bound) + for bound in (field.minimum, field.maximum) + ): + raise _settings_schema_error( + plugin_id, f"Number setting bounds must be finite: {field.key}" + ) + if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum: + raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}") + if field.type == PluginSettingType.secret and field.default is not None: + raise _settings_schema_error(plugin_id, f"Secret settings cannot declare defaults: {field.key}") + if field.default is not None: + try: + _validate_setting_value(field, field.default) + except ExtensionError as exc: + raise _settings_schema_error(plugin_id, exc.message) from exc + + +def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None: + if not _CONTRIBUTION_ID.fullmatch(spec.command_id) or not spec.command_id.startswith( + f"{plugin_id}." + ): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command id must be valid and use the Plugin namespace.", + details={"plugin_id": plugin_id, "command_id": spec.command_id}, + ) + if not spec.locations: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command must declare at least one location.", + details={"command_id": spec.command_id}, + ) + if len(spec.locations) != len(set(spec.locations)): + raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command locations must be unique.") + if len(spec.when) != len(set(spec.when)) or len(spec.context) != len(set(spec.context)): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command when/context entries must be unique.", + ) + if len(spec.secrets) != len(set(spec.secrets)): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command Secret entries must be unique.", + details={"command_id": spec.command_id}, + ) + unknown_when = sorted(set(spec.when) - _WHEN_TOKENS) + if unknown_when: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command declares unsupported when tokens.", + details={"command_id": spec.command_id, "when": unknown_when}, + ) + required_context = {_WHEN_CONTEXT[token] for token in spec.when} + if not required_context.issubset(set(spec.context)): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command context must include every field required by when.", + details={"command_id": spec.command_id}, + ) + if not set(spec.context).issubset(_CONTEXT_KEYS): + raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command context is invalid.") + if spec.icon and spec.icon not in _HOST_ICONS: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command icon is not a supported Host icon.", + details={"command_id": spec.command_id, "icon": spec.icon}, + ) + if spec.parameters.get("type", "object") != "object": + raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.") + try: + Draft202012Validator.check_schema(spec.parameters) + reject_external_schema_references(spec.parameters) + except (SchemaReferenceError, SchemaError) as exc: + message = exc.message if isinstance(exc, SchemaError) else str(exc) + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + f"Plugin command parameters contain invalid JSON Schema: {message}", + ) from exc + + +def _validate_setting_value(field: PluginSettingField, value: Any) -> None: + valid = False + if field.type == PluginSettingType.string: + valid = isinstance(value, str) and len(value.encode("utf-8")) <= 64 * 1024 + elif field.type == PluginSettingType.number: + valid = ( + (isinstance(value, int) and not isinstance(value, bool)) + or (isinstance(value, float) and math.isfinite(value)) + ) + elif field.type == PluginSettingType.boolean: + valid = isinstance(value, bool) + elif field.type == PluginSettingType.select: + valid = isinstance(value, str) and value in field.options + if not valid: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + f"Plugin setting has an invalid value: {field.key}", + details={"key": field.key}, + ) + if field.type == PluginSettingType.number: + if field.minimum is not None and value < field.minimum: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + f"Plugin setting is below its minimum: {field.key}", + details={"key": field.key, "minimum": field.minimum}, + ) + if field.maximum is not None and value > field.maximum: + raise ExtensionError( + "PLUGIN_SETTINGS_FIELD_INVALID", + f"Plugin setting is above its maximum: {field.key}", + details={"key": field.key, "maximum": field.maximum}, + ) + + +def _secret_field( + definition: PluginSettingsDefinition, plugin_id: str, key: str +) -> PluginSettingField: + field = next((item for item in definition.fields if item.key == key), None) + if field is None or field.type != PluginSettingType.secret: + raise ExtensionError( + "PLUGIN_SECRET_FIELD_NOT_FOUND", + f"Plugin secret field does not exist: {key}", + status_code=404, + details={"plugin_id": plugin_id, "key": key}, + ) + return field + + +def _secret_reference(plugin_id: str, key: str) -> str: + digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest() + return f"plugin.{digest}" + + +def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError: + return ExtensionError( + "PLUGIN_SETTINGS_SCHEMA_INVALID", + message, + details={"plugin_id": plugin_id}, + ) diff --git a/backend/app/extensions/errors.py b/backend/app/extensions/errors.py new file mode 100644 index 0000000..61ed7a8 --- /dev/null +++ b/backend/app/extensions/errors.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + + +class ExtensionError(RuntimeError): + """Extension Core 对 API 暴露的稳定领域错误。""" + + def __init__( + self, + code: str, + message: str, + *, + status_code: int = 422, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.details = details or {} diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py index 68f056b..dc3e62f 100644 --- a/backend/app/extensions/mcp.py +++ b/backend/app/extensions/mcp.py @@ -29,6 +29,10 @@ from app.contracts import ( PluginHostStatus, ToolDefinition, ) +from app.schema_security import ( + SchemaReferenceError, + reject_external_schema_references, +) MCP_PROTOCOL_VERSION = "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS = { @@ -677,10 +681,12 @@ class McpBridge: ) try: Draft202012Validator.check_schema(schema) - except SchemaError as exc: + reject_external_schema_references(schema) + except (SchemaReferenceError, SchemaError) as exc: + message = exc.message if isinstance(exc, SchemaError) else str(exc) raise McpBridgeError( "MCP_TOOL_SCHEMA_INVALID", - f"Invalid MCP tool schema for {remote_name}: {exc.message}", + f"Invalid MCP tool schema for {remote_name}: {message}", ) from exc metadata = raw.get("_meta") permission = ( diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 832b726..960554e 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -5,19 +5,40 @@ import threading from dataclasses import dataclass from pathlib import Path from typing import Any, Literal +from uuid import uuid4 import yaml from jsonschema import Draft202012Validator -from jsonschema.exceptions import SchemaError -from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model +from jsonschema.exceptions import ( + SchemaError, + ValidationError as JsonSchemaValidationError, +) +from pydantic import ( + BaseModel, + ConfigDict, + Field, + TypeAdapter, + ValidationError, + create_model, +) -from app.agent.tools import ToolExecutionContext, ToolRegistry +from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry from app.agent.permissions import KNOWN_PERMISSIONS from app.contracts import ( ModelCapability, Plugin, + PluginCommand, + PluginCommandContext, + PluginCommandEffect, + PluginNoEffect, + PluginNotificationEffect, + PluginCommandLocation, + PluginCommandResult, PluginManifest, PluginHostStatus, + PluginSecretStatus, + PluginSettingType, + PluginSettingsSchema, PluginStatus, RetrievalConfig, Skill, @@ -25,27 +46,26 @@ from app.contracts import ( SkillStatus, ToolDefinition, ) +from app.extensions.contributions import ( + CommandRegistry, + PluginCommandSpec, + PluginSecretResolver, + PluginSettingsDefinition, + PluginSettingsStore, + validate_command_spec, + validate_settings_definition, +) +from app.extensions.errors import ExtensionError from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool +from app.providers.credentials import EncryptedCredentialStore +from app.schema_security import ( + SchemaReferenceError, + reject_external_schema_references, +) _EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") -class ExtensionError(RuntimeError): - def __init__( - self, - code: str, - message: str, - *, - status_code: int = 422, - details: dict[str, Any] | None = None, - ) -> None: - super().__init__(message) - self.code = code - self.message = message - self.status_code = status_code - self.details = details or {} - - @dataclass(frozen=True, slots=True) class AgentConfiguration: skill_id: str @@ -238,14 +258,45 @@ class DeclarativePluginHost: return {"text": str(values.get("text", "")).upper()} raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}") + async def execute_command( + self, + handler: str, + arguments: dict[str, Any], + context: dict[str, Any], + settings: dict[str, Any], + resolve_secret: PluginSecretResolver, + ) -> PluginCommandEffect: + """执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。""" + + if handler == "echo": + message = str(arguments.get("message", context.get("selection", ""))) + if not message: + return PluginNoEffect() + return PluginNotificationEffect( + payload={"level": "info", "message": message}, + ) + if handler == "uppercase_selection": + text = str(arguments.get("text", context.get("selection", ""))) + limit = int(settings.get("result_limit", 100)) + return PluginNotificationEffect( + payload={"level": "success", "message": text[:limit].upper()}, + ) + raise ExtensionError( + "PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported command handler: {handler}" + ) + @dataclass(slots=True) class _PluginRecord: plugin: Plugin tools: list[DeclarativeToolSpec] + commands: list[PluginCommandSpec] + settings_definition: PluginSettingsDefinition | None package_path: Path registered_tools: list[str] + registered_commands: list[str] mcp_remote_names: dict[str, str] + mcp_command_schemas: dict[str, dict[str, Any]] class PluginRuntime: @@ -256,12 +307,15 @@ class PluginRuntime: tools: ToolRegistry, host: DeclarativePluginHost | None = None, mcp_bridge: McpBridge | None = None, + credentials: EncryptedCredentialStore | None = None, *, allow_unsandboxed_mcp: bool = False, ) -> None: self.registry = tools self.host = host or DeclarativePluginHost() self.mcp = mcp_bridge or McpBridge() + self.commands = CommandRegistry() + self.settings = PluginSettingsStore(credentials or EncryptedCredentialStore()) self.allow_unsandboxed_mcp = allow_unsandboxed_mcp self._records: dict[str, _PluginRecord] = {} self._lock = threading.RLock() @@ -287,6 +341,8 @@ class PluginRuntime: _validate_backend(manifest) specs = [] if manifest.backend.type == "mcp" else self._load_tools(root) + command_specs = self._load_commands(root) + settings_definition = self._load_settings(root) if manifest.backend.type != "mcp": declared = set(manifest.contributes.tools) actual = {spec.name for spec in specs} @@ -305,6 +361,89 @@ class PluginRuntime: f"Tool permission is not declared by Plugin: {spec.permission}", details={"tool": spec.name, "permission": spec.permission}, ) + declared_commands = set(manifest.contributes.commands) + actual_commands = {spec.command_id for spec in command_specs} + if ( + declared_commands != actual_commands + or len(manifest.contributes.commands) != len(declared_commands) + or len(command_specs) != len(actual_commands) + ): + raise ExtensionError( + "PLUGIN_CONTRIBUTION_INVALID", + "plugin.yaml command contributions must exactly match commands.yaml", + details={ + "declared": sorted(declared_commands), + "actual": sorted(actual_commands), + }, + ) + for spec in command_specs: + validate_command_spec(manifest.plugin_id, spec) + if spec.permission and spec.permission not in manifest.permissions: + raise ExtensionError( + "PLUGIN_PERMISSION_UNDECLARED", + f"Command permission is not declared by Plugin: {spec.permission}", + details={"command": spec.command_id, "permission": spec.permission}, + ) + declared_sections = set(manifest.contributes.settings_sections) + actual_sections = ( + {settings_definition.section_id} if settings_definition is not None else set() + ) + if ( + declared_sections != actual_sections + or len(manifest.contributes.settings_sections) != len(declared_sections) + ): + raise ExtensionError( + "PLUGIN_CONTRIBUTION_INVALID", + "plugin.yaml settings contributions must exactly match settings.yaml", + details={ + "declared": sorted(declared_sections), + "actual": sorted(actual_sections), + }, + ) + if settings_definition is not None: + validate_settings_definition(manifest.plugin_id, settings_definition) + secret_fields = ( + { + field.key + for field in settings_definition.fields + if field.type == PluginSettingType.secret + } + if settings_definition is not None + else set() + ) + for spec in command_specs: + unknown_secrets = sorted(set(spec.secrets) - secret_fields) + if unknown_secrets: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "Plugin command references undeclared Secret settings.", + details={ + "command_id": spec.command_id, + "secrets": unknown_secrets, + }, + ) + if spec.secrets and "secrets.use" not in manifest.permissions: + raise ExtensionError( + "PLUGIN_PERMISSION_UNDECLARED", + "Commands using Secret settings require the secrets.use permission.", + details={"command_id": spec.command_id}, + ) + if spec.mcp_tool is not None: + _validate_id("MCP command target", spec.mcp_tool) + if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith( + f"{manifest.plugin_id}." + ): + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "MCP Command target must use the current Plugin namespace.", + details={"command_id": spec.command_id}, + ) + if spec.mcp_tool in manifest.contributes.tools: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + "MCP Command target cannot also be exposed as an Agent Tool.", + details={"command_id": spec.command_id}, + ) record = _PluginRecord( plugin=Plugin( @@ -316,9 +455,13 @@ class PluginRuntime: ), ), tools=specs, + commands=command_specs, + settings_definition=settings_definition, package_path=root, registered_tools=[], + registered_commands=[], mcp_remote_names={}, + mcp_command_schemas={}, ) self._records[manifest.plugin_id] = record return record.plugin.model_copy(deep=True) @@ -359,6 +502,8 @@ class PluginRuntime: status_code=403, details={"plugin_id": plugin_id}, ) + if record.settings_definition is not None: + self.settings.runtime_values(plugin_id, record.settings_definition) declared_tools = list(record.plugin.manifest.contributes.tools) conflicts = [name for name in declared_tools if self.registry.contains(name)] if conflicts: @@ -368,20 +513,49 @@ class PluginRuntime: status_code=409, details={"plugin_id": plugin_id, "tools": conflicts}, ) + command_conflicts = [ + spec.command_id for spec in record.commands if self.commands.contains(spec.command_id) + ] + if command_conflicts: + raise ExtensionError( + "PLUGIN_COMMAND_CONFLICT", + "Plugin commands are already registered.", + status_code=409, + details={"plugin_id": plugin_id, "commands": command_conflicts}, + ) record.plugin.status = PluginStatus.starting try: if record.plugin.manifest.backend.type == "mcp": discovered = self._start_mcp(record) actual = {item.definition.name for item in discovered} declared = set(declared_tools) - if actual != declared: + command_targets = { + spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None + } + expected = declared | command_targets + if actual != expected: raise ExtensionError( "PLUGIN_CONTRIBUTION_INVALID", - "Discovered MCP tools must exactly match Plugin contributions.", - details={"declared": sorted(declared), "actual": sorted(actual)}, + "Discovered MCP tools must exactly match Tool and Command targets.", + details={"declared": sorted(expected), "actual": sorted(actual)}, ) for item in discovered: - self._register_mcp_tool(record, item) + if item.definition.name in declared: + self._register_mcp_tool(record, item) + else: + record.mcp_remote_names[item.definition.name] = item.remote_name + record.mcp_command_schemas[item.definition.name] = ( + item.definition.parameters + ) + for spec in ( + command + for command in record.commands + if command.mcp_tool == item.definition.name + ): + _validate_mcp_command_target_schema( + item.definition.parameters, + spec.command_id, + ) else: for spec in record.tools: arguments_model = _arguments_model(spec) @@ -405,12 +579,135 @@ class PluginRuntime: executor, ) record.registered_tools.append(spec.name) + for spec in record.commands: + + async def command_executor( + arguments: dict[str, Any], + context: dict[str, Any], + _spec: PluginCommandSpec = spec, + _record: _PluginRecord = record, + ) -> PluginCommandEffect: + if ( + not _record.plugin.enabled + or _record.plugin.status != PluginStatus.ready + ): + raise ExtensionError( + "PLUGIN_COMMAND_NOT_FOUND", + "Plugin command is not available while its Plugin is inactive.", + status_code=404, + details={"command_id": _spec.command_id}, + ) + settings = ( + self.settings.runtime_values( + _record.plugin.manifest.plugin_id, + _record.settings_definition, + ) + if _record.settings_definition is not None + else {} + ) + + def resolve_secret(key: str) -> str | None: + if key not in _spec.secrets: + raise ExtensionError( + "PLUGIN_SECRET_ACCESS_DENIED", + "Command cannot access an undeclared Plugin Secret.", + status_code=403, + details={ + "command_id": _spec.command_id, + "key": key, + }, + ) + if "secrets.use" not in _record.plugin.granted_permissions: + raise ExtensionError( + "PLUGIN_SECRET_ACCESS_DENIED", + "Plugin no longer has permission to access Secret settings.", + status_code=403, + details={"command_id": _spec.command_id, "key": key}, + ) + if _record.settings_definition is None: + return None + value = self.settings.resolve_secret( + _record.plugin.manifest.plugin_id, + _record.settings_definition, + key, + ) + field = next( + item + for item in _record.settings_definition.fields + if item.key == key + ) + if field.required and value is None: + raise ExtensionError( + "PLUGIN_SECRET_REQUIRED", + "A required Plugin Secret has not been configured.", + status_code=409, + details={"command_id": _spec.command_id, "key": key}, + ) + return value + + if _spec.mcp_tool is not None: + remote_name = _record.mcp_remote_names[_spec.mcp_tool] + secret_values = { + key: value + for key in _spec.secrets + if (value := resolve_secret(key)) is not None + } + envelope = _mcp_command_envelope( + _spec, + arguments=arguments, + context=context, + settings=settings, + secrets=secret_values, + ) + _validate_mcp_command_envelope( + _record.mcp_command_schemas[_spec.mcp_tool], + envelope, + _spec.command_id, + ) + try: + effect = await self.mcp.call_tool( + _record.plugin.manifest.plugin_id, + remote_name, + envelope, + request_id=f"command:{uuid4().hex}", + ) + except ToolExecutionError as exc: + raise ExtensionError( + exc.code, + "MCP Command target execution failed.", + status_code=502, + details={"command_id": _spec.command_id}, + ) from exc + try: + return TypeAdapter(PluginCommandEffect).validate_python(effect) + except ValidationError as exc: + raise ExtensionError( + "PLUGIN_COMMAND_RESULT_INVALID", + "MCP Command target returned an invalid effect.", + status_code=502, + details={"command_id": _spec.command_id}, + ) from exc + + return await self.host.execute_command( + _spec.handler, + arguments, + context, + settings, + resolve_secret, + ) + + self.commands.register(plugin_id, spec, command_executor) + record.registered_commands.append(spec.command_id) except Exception as exc: # 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。 for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + for command_id in record.registered_commands: + self.commands.unregister(command_id) + record.registered_commands.clear() record.mcp_remote_names.clear() + record.mcp_command_schemas.clear() self.mcp.stop(plugin_id) record.plugin.status = PluginStatus.error record.plugin.error_message = _safe_extension_message(exc) @@ -468,7 +765,11 @@ class PluginRuntime: for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + for command_id in record.registered_commands: + self.commands.unregister(command_id) + record.registered_commands.clear() record.mcp_remote_names.clear() + record.mcp_command_schemas.clear() if record.plugin.manifest.backend.type == "mcp": self.mcp.stop(plugin_id) record.plugin.enabled = False @@ -479,6 +780,43 @@ class PluginRuntime: record = self._record(plugin_id) return self.mcp.status(plugin_id, record.plugin.manifest.backend) + def list_commands( + self, location: PluginCommandLocation | None = None + ) -> list[PluginCommand]: + return self.commands.list(location) + + async def execute_command( + self, + command_id: str, + arguments: dict[str, Any], + context: PluginCommandContext, + ) -> PluginCommandResult: + return await self.commands.execute(command_id, arguments, context) + + def get_settings(self, plugin_id: str) -> PluginSettingsSchema: + record = self._record(plugin_id) + definition = self._settings_definition(record) + return self.settings.get(plugin_id, definition) + + def update_settings( + self, plugin_id: str, schema_version: int, values: dict[str, Any] + ) -> PluginSettingsSchema: + record = self._record(plugin_id) + definition = self._settings_definition(record) + return self.settings.update(plugin_id, definition, schema_version, values) + + def put_setting_secret( + self, plugin_id: str, key: str, secret: str + ) -> PluginSecretStatus: + record = self._record(plugin_id) + definition = self._settings_definition(record) + return self.settings.put_secret(plugin_id, definition, key, secret) + + def delete_setting_secret(self, plugin_id: str, key: str) -> PluginSecretStatus: + record = self._record(plugin_id) + definition = self._settings_definition(record) + return self.settings.delete_secret(plugin_id, definition, key) + def restart_host(self, plugin_id: str) -> PluginHostStatus: with self._lock: return self._restart_host(plugin_id) @@ -506,7 +844,11 @@ class PluginRuntime: for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + for command_id in record.registered_commands: + self.commands.unregister(command_id) + record.registered_commands.clear() record.mcp_remote_names.clear() + record.mcp_command_schemas.clear() self.mcp.stop(plugin_id) record.plugin.enabled = False record.plugin.status = PluginStatus.installed @@ -567,7 +909,11 @@ class PluginRuntime: for name in record.registered_tools: self.registry.unregister(name) record.registered_tools.clear() + for command_id in record.registered_commands: + self.commands.unregister(command_id) + record.registered_commands.clear() record.mcp_remote_names.clear() + record.mcp_command_schemas.clear() record.plugin.enabled = False record.plugin.status = PluginStatus.error record.plugin.error_message = message @@ -594,6 +940,7 @@ class PluginRuntime: # stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同 # 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。 self.mcp.remove(plugin_id) + self.settings.remove_plugin(plugin_id) del self._records[plugin_id] def _record(self, plugin_id: str) -> _PluginRecord: @@ -615,6 +962,52 @@ class PluginRuntime: except ValidationError as exc: raise _manifest_error("plugin tool", exc) from exc + @staticmethod + def _load_commands(root: Path) -> list[PluginCommandSpec]: + path = root / "commands.yaml" + if not path.exists(): + return [] + raw = _read_yaml(path) + items = raw.get("commands", []) + if not isinstance(items, list): + raise ExtensionError( + "EXTENSION_MANIFEST_INVALID", + "Invalid plugin command manifest: commands must be an array.", + ) + try: + return [ + PluginCommandSpec.model_validate(item) + for item in items + ] + except ValidationError as exc: + raise _manifest_error("plugin command", exc) from exc + + @staticmethod + def _load_settings(root: Path) -> PluginSettingsDefinition | None: + path = root / "settings.yaml" + if not path.exists(): + return None + raw = _read_yaml(path) + try: + return PluginSettingsDefinition.model_validate(raw) + except ValidationError as exc: + raise ExtensionError( + "PLUGIN_SETTINGS_SCHEMA_INVALID", + "Invalid Plugin settings schema.", + details={"errors": exc.errors(include_url=False)}, + ) from exc + + @staticmethod + def _settings_definition(record: _PluginRecord) -> PluginSettingsDefinition: + if record.settings_definition is None: + raise ExtensionError( + "PLUGIN_SETTINGS_NOT_FOUND", + "Plugin does not contribute a Settings section.", + status_code=404, + details={"plugin_id": record.plugin.manifest.plugin_id}, + ) + return record.settings_definition + def _package_dir(package_path: str | Path) -> Path: root = Path(package_path).expanduser().resolve() @@ -673,6 +1066,61 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]: return _arguments_model_from_schema(spec.name, schema) +def _mcp_command_envelope( + spec: PluginCommandSpec, + *, + arguments: dict[str, Any], + context: dict[str, Any], + settings: dict[str, Any], + secrets: dict[str, str], +) -> dict[str, Any]: + return { + "_notesagent": { + "command_id": spec.command_id, + "arguments": arguments, + "context": context, + "settings": settings, + "secrets": secrets, + } + } + + +def _validate_mcp_command_envelope( + schema: dict[str, Any], + envelope: dict[str, Any], + command_id: str, +) -> None: + """执行前用目标 Tool Schema 校验包含真实业务数据的宿主信封。""" + + try: + Draft202012Validator(schema).validate(envelope) + except JsonSchemaValidationError as exc: + raise ExtensionError( + "PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH", + "MCP Command envelope does not match the target inputSchema.", + status_code=502, + details={"command_id": command_id, "path": list(exc.path)}, + ) from exc + + +def _validate_mcp_command_target_schema( + schema: dict[str, Any], command_id: str +) -> None: + """启用时只检查稳定信封入口,避免用伪造业务值误判合法 Schema。""" + + properties = schema.get("properties") + envelope_schema = ( + properties.get("_notesagent") if isinstance(properties, dict) else None + ) + if not isinstance(envelope_schema, dict) or envelope_schema.get("type") != "object": + raise ExtensionError( + "PLUGIN_CONTRIBUTION_INVALID", + "MCP Command target inputSchema must directly declare " + "_notesagent with type object.", + details={"command_id": command_id}, + ) + + def _arguments_model_from_schema( tool_name: str, schema: dict[str, Any] ) -> type[BaseModel]: @@ -688,10 +1136,12 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: schema = spec.parameters or {"type": "object", "properties": {}} try: Draft202012Validator.check_schema(schema) - except SchemaError as exc: + reject_external_schema_references(schema) + except (SchemaReferenceError, SchemaError) as exc: + message = exc.message if isinstance(exc, SchemaError) else str(exc) raise ExtensionError( "PLUGIN_TOOL_SCHEMA_INVALID", - f"Invalid JSON Schema for tool {spec.name}: {exc.message}", + f"Invalid JSON Schema for tool {spec.name}: {message}", details={"tool": spec.name}, ) from exc if schema.get("type", "object") != "object" or not isinstance( diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index 19e83c3..88d9485 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -13,6 +13,7 @@ from app.config import get_settings _CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PLUGIN_CREDENTIAL_PREFIX = "plugin." class CredentialStoreError(RuntimeError): @@ -23,6 +24,15 @@ class CredentialResolver(Protocol): def resolve(self, credential_id: str | None) -> str | None: ... +def validate_provider_credential_id(credential_id: str | None) -> None: + """阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。""" + + if credential_id and credential_id.casefold().startswith( + _PLUGIN_CREDENTIAL_PREFIX + ): + raise CredentialStoreError("Credential namespace is reserved for Plugin settings.") + + class EnvironmentCredentialResolver: """解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。""" @@ -109,17 +119,26 @@ class EncryptedCredentialStore: def _write_tokens(self, tokens: dict[str, str]) -> None: _, store_path = self._paths() - store_path.parent.mkdir(parents=True, exist_ok=True) - self._restrict(store_path.parent, 0o700) temporary = store_path.with_suffix(".tmp") - temporary.write_text( - json.dumps(tokens, ensure_ascii=True, sort_keys=True), - encoding="utf-8", - ) - self._restrict(temporary, 0o600) - # 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。 - temporary.replace(store_path) - self._restrict(store_path, 0o600) + try: + store_path.parent.mkdir(parents=True, exist_ok=True) + self._restrict(store_path.parent, 0o700) + temporary.write_text( + json.dumps(tokens, ensure_ascii=True, sort_keys=True), + encoding="utf-8", + ) + self._restrict(temporary, 0o600) + # 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。 + temporary.replace(store_path) + self._restrict(store_path, 0o600) + except OSError as exc: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise CredentialStoreError( + "Encrypted credential store cannot be written." + ) from exc def put(self, credential_id: str, secret: str) -> None: self._validate_id(credential_id) @@ -158,6 +177,24 @@ class EncryptedCredentialStore: self._write_tokens(tokens) return removed + def delete_many(self, credential_ids: list[str]) -> set[str]: + """用一次原子替换删除多个凭据,避免插件卸载只删除部分 Secret。""" + + for credential_id in credential_ids: + self._validate_id(credential_id) + with self._lock: + tokens = self._read_tokens() + removed = { + credential_id + for credential_id in credential_ids + if credential_id in tokens + } + if removed: + for credential_id in removed: + del tokens[credential_id] + self._write_tokens(tokens) + return removed + class ChainedCredentialResolver: def __init__(self, *resolvers: CredentialResolver) -> None: @@ -170,3 +207,14 @@ class ChainedCredentialResolver: if value: return value return None + + +class ProviderCredentialResolver: + """Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。""" + + def __init__(self, delegate: CredentialResolver) -> None: + self._delegate = delegate + + def resolve(self, credential_id: str | None) -> str | None: + validate_provider_credential_id(credential_id) + return self._delegate.resolve(credential_id) diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 38d1687..84f08a7 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -1,6 +1,6 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType from app.providers.base import ModelProvider -from app.providers.credentials import CredentialResolver +from app.providers.credentials import CredentialResolver, ProviderCredentialResolver from app.providers.ollama import OllamaProvider from app.providers.openai_compatible import OpenAICompatibleProvider @@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError): class ProviderFactory: def __init__(self, credentials: CredentialResolver) -> None: - self.credentials = credentials + # ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止 + # Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。 + self.credentials = ProviderCredentialResolver(credentials) def build(self, config: ProviderConfig) -> ModelProvider: if config.provider_type in { diff --git a/backend/app/routes.py b/backend/app/routes.py index 201973e..962bec7 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -33,9 +33,17 @@ from app.contracts import ( PageMeta, PermissionDecisionRequest, Plugin, + PluginCommandExecuteRequest, + PluginCommandListResponse, + PluginCommandLocation, + PluginCommandResult, PluginHostStatus, PluginListResponse, PluginPermissionGrantRequest, + PluginSecretStatus, + PluginSecretWriteRequest, + PluginSettingsSchema, + PluginSettingsUpdateRequest, ProviderConfig, ProviderCreateRequest, ProviderListResponse, @@ -67,7 +75,10 @@ from app.extensions import ExtensionError from app.providers.registry import ProviderNotFoundError from app.providers.factory import UnsupportedProviderError from app.providers.base import ProviderError -from app.providers.credentials import CredentialStoreError +from app.providers.credentials import ( + CredentialStoreError, + validate_provider_credential_id, +) from app.retrieval.engine import engine from app.services import ( index_service, @@ -84,6 +95,13 @@ def utc_now() -> datetime: return datetime.now(timezone.utc) +def validate_public_credential_id(credential_id: str | None) -> None: + try: + validate_provider_credential_id(credential_id) + except CredentialStoreError as exc: + raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc + + def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str: id_line = f"id: {event_id}\n" if event_id is not None else "" return f"{id_line}event: {event}\ndata: {payload}\n\n" @@ -559,6 +577,86 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse: return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled") +# Plugin Command / Settings Contributions +@router.get( + "/plugin-contributions/commands", + response_model=PluginCommandListResponse, + tags=["Plugins"], +) +async def list_plugin_commands( + location: PluginCommandLocation | None = Query(default=None), +) -> PluginCommandListResponse: + return PluginCommandListResponse(items=container.plugins.list_commands(location)) + + +@router.post( + "/plugin-contributions/commands/{command_id}/execute", + response_model=PluginCommandResult, + tags=["Plugins"], +) +async def execute_plugin_command( + command_id: str, request: PluginCommandExecuteRequest +) -> PluginCommandResult: + try: + return await container.plugins.execute_command( + command_id, request.arguments, request.context + ) + except ExtensionError as exc: + raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc + + +@router.get( + "/plugins/{plugin_id}/settings", + response_model=PluginSettingsSchema, + tags=["Plugins"], +) +async def get_plugin_settings(plugin_id: str) -> PluginSettingsSchema: + return extension_call(lambda: container.plugins.get_settings(plugin_id)) + + +@router.put( + "/plugins/{plugin_id}/settings", + response_model=PluginSettingsSchema, + tags=["Plugins"], +) +async def update_plugin_settings( + plugin_id: str, request: PluginSettingsUpdateRequest +) -> PluginSettingsSchema: + return extension_call( + lambda: container.plugins.update_settings( + plugin_id, request.schema_version, request.values + ) + ) + + +@router.put( + "/plugins/{plugin_id}/settings/{key}/secret", + response_model=PluginSecretStatus, + tags=["Plugins"], +) +async def put_plugin_setting_secret( + plugin_id: str, key: str, request: PluginSecretWriteRequest +) -> PluginSecretStatus: + return extension_call( + lambda: container.plugins.put_setting_secret( + plugin_id, key, request.secret.get_secret_value() + ) + ) + + +@router.delete( + "/plugins/{plugin_id}/settings/{key}/secret", + response_model=PluginSecretStatus, + tags=["Plugins"], +) +async def delete_plugin_setting_secret( + plugin_id: str, key: str +) -> PluginSecretStatus: + return extension_call( + lambda: container.plugins.delete_setting_secret(plugin_id, key) + ) + + # Providers @router.get( "/credentials/{credential_id}", @@ -566,6 +664,7 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse: tags=["Providers"], ) async def get_credential_status(credential_id: str) -> CredentialStatus: + validate_public_credential_id(credential_id) try: configured = container.credentials.has(credential_id) except CredentialStoreError as exc: @@ -581,6 +680,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus: async def put_credential( credential_id: str, request: CredentialWriteRequest ) -> CredentialStatus: + validate_public_credential_id(credential_id) try: container.credentials.put(credential_id, request.api_key.get_secret_value()) except CredentialStoreError as exc: @@ -594,6 +694,7 @@ async def put_credential( tags=["Providers"], ) async def delete_credential(credential_id: str) -> CredentialStatus: + validate_public_credential_id(credential_id) try: container.credentials.delete(credential_id) except CredentialStoreError as exc: @@ -630,6 +731,7 @@ async def get_provider(provider_id: str) -> ProviderConfig: tags=["Providers"], ) async def create_provider(request: ProviderCreateRequest) -> ProviderConfig: + validate_public_credential_id(request.credential_id) config = ProviderConfig( provider_id=f"provider_{uuid4().hex}", provider_type=request.provider_type, @@ -673,6 +775,8 @@ async def update_provider( "name and enabled cannot be null when explicitly provided.", ) updates = {name: getattr(request, name) for name in fields} + if "credential_id" in fields: + validate_public_credential_id(request.credential_id) config = ProviderConfig.model_validate( {**current.model_dump(mode="python"), **updates} ) @@ -732,6 +836,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse: async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse: registered = configurable_provider_or_404(request.provider_id) if request.credential_context_id: + validate_public_credential_id(request.credential_context_id) temporary_config = registered.config.model_copy( update={"credential_id": request.credential_context_id, "enabled": True} ) diff --git a/backend/app/schema_security.py b/backend/app/schema_security.py new file mode 100644 index 0000000..791fa62 --- /dev/null +++ b/backend/app/schema_security.py @@ -0,0 +1,60 @@ +"""共享 JSON Schema 安全约束。""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin + +from referencing import Registry +from referencing.exceptions import Unresolvable +from referencing.jsonschema import DRAFT202012 + +_SCHEMA_BASE_URI = "https://notesagent.invalid/local-schema" + + +class SchemaReferenceError(ValueError): + """Schema 引用不符合宿主的离线、文档内解析约束。""" + + +class ExternalSchemaReferenceError(SchemaReferenceError): + def __init__(self, keyword: str, reference: Any) -> None: + super().__init__(f"External JSON Schema reference is not allowed: {reference!r}") + self.keyword = keyword + self.reference = reference + + +class UnresolvableLocalSchemaReferenceError(SchemaReferenceError): + def __init__(self, reference: str) -> None: + super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}") + self.reference = reference + + +def reject_external_schema_references(schema: Any) -> None: + """只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析。""" + + root = DRAFT202012.create_resource(schema) + root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "") + registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl() + resolver = registry.resolver(root_uri) + _validate_resource_references(root, resolver) + + +def _validate_resource_references(resource, resolver: Any) -> None: + contents = resource.contents + if isinstance(contents, dict): + for keyword in ("$ref", "$dynamicRef"): + if keyword not in contents: + continue + reference = contents[keyword] + if not isinstance(reference, str) or not reference.startswith("#"): + raise ExternalSchemaReferenceError(keyword, reference) + try: + resolver.lookup(reference) + except Unresolvable as exc: + raise UnresolvableLocalSchemaReferenceError(reference) from exc + + for subresource in resource.subresources(): + _validate_resource_references( + subresource, + resolver.in_subresource(subresource), + ) diff --git a/backend/extensions/fixtures/mcp-echo/commands.yaml b/backend/extensions/fixtures/mcp-echo/commands.yaml new file mode 100644 index 0000000..0428b86 --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/commands.yaml @@ -0,0 +1,20 @@ +commands: + - command_id: mcp-fixture.notify + title: MCP 通知 + description: 通过隔离 MCP Host 返回宿主白名单通知 effect。 + icon: bolt + locations: + - command_palette + when: + - editor.has_selection + context: + - selection + secrets: + - api_key + mcp_tool: mcp-fixture.command + parameters: + type: object + properties: + message: + type: string + additionalProperties: false diff --git a/backend/extensions/fixtures/mcp-echo/plugin.yaml b/backend/extensions/fixtures/mcp-echo/plugin.yaml index 34c4312..06fc59e 100644 --- a/backend/extensions/fixtures/mcp-echo/plugin.yaml +++ b/backend/extensions/fixtures/mcp-echo/plugin.yaml @@ -1,9 +1,10 @@ id: mcp-fixture name: MCP Fixture version: 1.0.0 -description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。 +description: 阶段 C/D 离线联调 Fixture,覆盖 MCP Tool、Command 与错误边界。 permissions: - notes.read + - secrets.use contributes: tools: - mcp-fixture.echo @@ -12,6 +13,10 @@ contributes: - mcp-fixture.large - mcp-fixture.environment - mcp-fixture.exit + commands: + - mcp-fixture.notify + settings_sections: + - mcp-fixture.general backend: type: mcp transport: stdio diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py index b1fefda..fed5e55 100644 --- a/backend/extensions/fixtures/mcp-echo/server.py +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -54,6 +54,11 @@ TOOLS = { "large": tool("large", "Return a result larger than the host limit."), "environment": tool("environment", "Report whether host secrets leaked into the process."), "exit": tool("exit", "Terminate the fixture process."), + "command": tool( + "command", + "Execute a NotesAgent Plugin Command envelope.", + {"_notesagent": {"type": "object"}}, + ), } # suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。 TOOLS["echo"]["inputSchema"]["required"] = ["text"] @@ -62,6 +67,38 @@ TOOLS["echo"]["inputSchema"]["required"] = ["text"] def call_tool(request_id: int, params: dict[str, Any]) -> None: name = params.get("name") arguments = params.get("arguments") or {} + if name == "command": + envelope = arguments.get("_notesagent") or {} + command_arguments = envelope.get("arguments") or {} + context = envelope.get("context") or {} + settings = envelope.get("settings") or {} + secrets = envelope.get("secrets") or {} + if not isinstance(secrets.get("api_key"), str): + respond( + request_id, + { + "content": [{"type": "text", "text": "declared secret missing"}], + "isError": True, + }, + ) + return + message = command_arguments.get("message") or context.get("selection") or "" + message = f"{settings.get('message_prefix', '')}{message}" + respond( + request_id, + { + "content": [{"type": "text", "text": "command completed"}], + "structuredContent": { + "type": "notification", + "payload": { + "level": "success", + "message": str(message), + }, + }, + "isError": False, + }, + ) + return if name == "echo": text = str(arguments.get("text", "")) structured_content = {"echo": text} @@ -183,7 +220,14 @@ def main() -> None: elif params.get("cursor") == "page-2": respond( request_id, - {"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]}, + { + "tools": [ + TOOLS["large"], + TOOLS["environment"], + TOOLS["exit"], + TOOLS["command"], + ] + }, ) else: respond( diff --git a/backend/extensions/fixtures/mcp-echo/settings.yaml b/backend/extensions/fixtures/mcp-echo/settings.yaml new file mode 100644 index 0000000..3ddb40e --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/settings.yaml @@ -0,0 +1,11 @@ +section_id: mcp-fixture.general +schema_version: 1 +fields: + - key: message_prefix + label: Message Prefix + type: string + default: "" + - key: api_key + label: Fixture API Key + type: secret + required: true diff --git a/backend/extensions/plugins/text-tools/commands.yaml b/backend/extensions/plugins/text-tools/commands.yaml new file mode 100644 index 0000000..611b516 --- /dev/null +++ b/backend/extensions/plugins/text-tools/commands.yaml @@ -0,0 +1,19 @@ +commands: + - command_id: text-tools.uppercase-selection + title: 转为大写 + description: 将当前选区或传入文本转换为大写并显示通知。 + icon: edit + locations: + - command_palette + - context_menu + when: + - editor.has_selection + context: + - selection + handler: uppercase_selection + parameters: + type: object + properties: + text: + type: string + additionalProperties: false diff --git a/backend/extensions/plugins/text-tools/plugin.yaml b/backend/extensions/plugins/text-tools/plugin.yaml index 4f70c0f..272d05f 100644 --- a/backend/extensions/plugins/text-tools/plugin.yaml +++ b/backend/extensions/plugins/text-tools/plugin.yaml @@ -6,6 +6,10 @@ permissions: [] contributes: tools: - text.uppercase + commands: + - text-tools.uppercase-selection + settings_sections: + - text-tools.general backend: type: internal_rpc transport: none diff --git a/backend/extensions/plugins/text-tools/settings.yaml b/backend/extensions/plugins/text-tools/settings.yaml new file mode 100644 index 0000000..171ef27 --- /dev/null +++ b/backend/extensions/plugins/text-tools/settings.yaml @@ -0,0 +1,31 @@ +section_id: text-tools.general +schema_version: 1 +fields: + - key: result_limit + label: 结果字符数 + description: Command 通知中最多保留的字符数。 + type: number + required: true + default: 100 + minimum: 1 + maximum: 1000 + - key: label_prefix + label: 标签前缀 + type: string + default: "" + - key: output_style + label: 输出样式 + type: select + default: notification + options: + - notification + - compact + - key: enabled_hint + label: 显示提示 + type: boolean + default: true + - key: api_key + label: API Key + description: Secret 示例字段;普通 Settings API 永不返回明文。 + type: secret + required: false diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3688aaa..6c3d4c0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "httpx>=0.28,<1.0", "jsonschema>=4.25,<5.0", "pyyaml>=6.0,<7.0", + "referencing>=0.36,<1.0", "sqlite-vec>=0.1.9", "uvicorn[standard]>=0.35,<1.0", ] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e76a1e8..3d5d2d5 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -95,6 +95,10 @@ def test_openapi_contains_documented_frontend_interfaces() -> None: "/api/plugins/install", "/api/plugins/{plugin_id}/host", "/api/plugins/{plugin_id}/host/restart", + "/api/plugin-contributions/commands", + "/api/plugin-contributions/commands/{command_id}/execute", + "/api/plugins/{plugin_id}/settings", + "/api/plugins/{plugin_id}/settings/{key}/secret", "/api/plugins/{plugin_id}/enable", "/api/plugins/{plugin_id}/disable", "/api/providers/test", diff --git a/backend/tests/test_credentials.py b/backend/tests/test_credentials.py index d985827..ca09176 100644 --- a/backend/tests/test_credentials.py +++ b/backend/tests/test_credentials.py @@ -1,16 +1,21 @@ import asyncio +from pathlib import Path import httpx +import pytest from app.config import get_settings from app.contracts import CredentialWriteRequest +from app.errors import ApiError from app.providers.credentials import ( ChainedCredentialResolver, + CredentialStoreError, EncryptedCredentialStore, EnvironmentCredentialResolver, ) +from app.providers.factory import ProviderFactory from app.providers.openai_compatible import OpenAICompatibleProvider -from app.routes import get_credential_status, put_credential +from app.routes import delete_credential, get_credential_status, put_credential def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None: @@ -31,6 +36,33 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No assert store.resolve("deepseek") is None +def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None: + store = EncryptedCredentialStore() + store.put("plugin.first", "first") + store.put("plugin.second", "second") + store.put("openai", "keep") + + removed = store.delete_many(["plugin.first", "plugin.second"]) + + assert removed == {"plugin.first", "plugin.second"} + assert store.resolve("plugin.first") is None + assert store.resolve("plugin.second") is None + assert store.resolve("openai") == "keep" + + +def test_credential_write_os_error_uses_stable_store_error(monkeypatch) -> None: + store = EncryptedCredentialStore() + store.put("existing", "value") + + def fail_replace(_path: Path, _target: Path) -> Path: + raise OSError("injected replace failure") + + monkeypatch.setattr(Path, "replace", fail_replace) + + with pytest.raises(CredentialStoreError, match="cannot be written"): + store.put("new", "value") + + def test_credential_api_never_returns_secret() -> None: written = asyncio.run( put_credential( @@ -72,3 +104,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver()) assert resolver.resolve("deepseek") == "saved-key" + + +def test_public_credential_api_rejects_plugin_namespace() -> None: + operations = [ + get_credential_status("plugin.text-tools.api_key"), + put_credential( + "plugin.text-tools.api_key", + CredentialWriteRequest(api_key="must-not-write"), + ), + delete_credential("plugin.text-tools.api_key"), + ] + for operation in operations: + with pytest.raises(ApiError) as exc: + asyncio.run(operation) + assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED" + + assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None + + +def test_provider_resolver_cannot_read_plugin_secret() -> None: + store = EncryptedCredentialStore() + store.put("plugin.text-tools.api_key", "private-plugin-secret") + resolver = ProviderFactory(store).credentials + + with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"): + resolver.resolve("plugin.text-tools.api_key") diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 29a2d92..bdffc8c 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -11,12 +11,16 @@ from app.container import build_container from app.contracts import ( AgentRunCreateRequest, AgentRunStatus, + PluginCommandContext, SkillStatus, ToolCall, ) from app.extensions import ExtensionError from app.extensions.mcp import McpStdioClient -from app.extensions.runtime import _arguments_model_from_schema +from app.extensions.runtime import ( + _arguments_model_from_schema, + _validate_mcp_command_target_schema, +) from app.services import note_service from app.config import BACKEND_DIR, get_settings @@ -33,7 +37,7 @@ def mcp_container(): container = build_container() installed = container.plugins.install(MCP_FIXTURE) assert installed.status == "permission_required" - container.plugins.set_permissions("mcp-fixture", ["notes.read"]) + container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"]) try: yield container finally: @@ -349,7 +353,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( ToolExecutionContext(run_id="run_mcp_fixture"), ) - assert status.tools_count == 6 + assert status.tools_count == 7 assert status.protocol_version == "2025-11-25" assert status.server_name == "notesagent-mcp-fixture" assert definition.permission == "notes.read" @@ -396,6 +400,162 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results( run(scenario()) +def test_mcp_command_target_receives_scoped_context_and_declared_secret( + mcp_container, +) -> None: + async def scenario() -> None: + mcp_container.plugins.enable("mcp-fixture") + + assert not mcp_container.tools.contains("mcp-fixture.command") + with pytest.raises(ExtensionError) as missing: + await mcp_container.plugins.execute_command( + "mcp-fixture.notify", + {}, + PluginCommandContext(selection="来自选区"), + ) + assert missing.value.code == "PLUGIN_SECRET_REQUIRED" + + mcp_container.plugins.put_setting_secret( + "mcp-fixture", "api_key", "mcp-command-secret" + ) + mcp_container.plugins.update_settings( + "mcp-fixture", 1, {"message_prefix": "Fixture: "} + ) + result = await mcp_container.plugins.execute_command( + "mcp-fixture.notify", + {}, + PluginCommandContext( + note_id="must-not-enter-envelope", + selection="来自选区", + ), + ) + + assert result.effect.type == "notification" + assert result.effect.payload.model_dump() == { + "level": "success", + "message": "Fixture: 来自选区", + } + assert "mcp-command-secret" not in repr( + mcp_container.plugins.commands.audit_events() + ) + + run(scenario()) + + +def test_mcp_command_target_rejects_incompatible_envelope_schema(tmp_path) -> None: + package = tmp_path / "mcp-bad-command" + shutil.copytree(MCP_FIXTURE, package) + for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"): + path = package / filename + path.write_text( + path.read_text(encoding="utf-8").replace( + "mcp-fixture", "mcp-bad-command" + ), + encoding="utf-8", + ) + server_path = package / "server.py" + server_path.write_text( + server_path.read_text(encoding="utf-8").replace( + '{"_notesagent": {"type": "object"}}', + '{"unexpected": {"type": "string"}}', + ), + encoding="utf-8", + ) + container = build_container() + container.plugins.install(package) + container.plugins.set_permissions( + "mcp-bad-command", ["notes.read", "secrets.use"] + ) + try: + with pytest.raises(ExtensionError) as exc: + container.plugins.enable("mcp-bad-command") + assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID" + assert container.plugins.get("mcp-bad-command").status == "error" + finally: + container.plugins.shutdown() + + +def test_mcp_command_target_enable_check_only_requires_protocol_marker() -> None: + # `not`/`oneOf` 等完整语义由实际调用前的官方 Validator 处理;启用检查 + # 只确认不可被引用或组合隐藏的稳定宿主入口,避免维护不完整的求解器。 + _validate_mcp_command_target_schema( + { + "type": "object", + "properties": { + "_notesagent": { + "type": "object", + "not": {"type": "object"}, + } + }, + }, + "marker.run", + ) + + invalid_markers = [ + { + "$defs": {"envelope": {"type": "object"}}, + "properties": {"_notesagent": {"$ref": "#/$defs/envelope"}}, + }, + { + "allOf": [ + {"properties": {"_notesagent": {"type": "object"}}}, + ] + }, + ] + for schema in invalid_markers: + with pytest.raises(ExtensionError) as exc: + _validate_mcp_command_target_schema(schema, "marker.run") + assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID" + + +def test_mcp_command_validates_actual_envelope_before_call(tmp_path) -> None: + package = tmp_path / "mcp-runtime-schema" + shutil.copytree(MCP_FIXTURE, package) + for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"): + path = package / filename + path.write_text( + path.read_text(encoding="utf-8").replace( + "mcp-fixture", "mcp-runtime-schema" + ), + encoding="utf-8", + ) + server_path = package / "server.py" + server_path.write_text( + server_path.read_text(encoding="utf-8").replace( + '{"_notesagent": {"type": "object"}}', + '{"_notesagent": {"type": "object", "properties": ' + '{"arguments": {"type": "object", "maxProperties": 0}, ' + '"context": {"type": "object", "properties": ' + '{"selection": {"type": "string"}}, "required": ["selection"]}}, ' + '"required": ["arguments", "context"]}}', + ), + encoding="utf-8", + ) + container = build_container() + container.plugins.install(package) + container.plugins.set_permissions( + "mcp-runtime-schema", ["notes.read", "secrets.use"] + ) + try: + # context.selection 是 Command 的 when/context 契约保证的真实字段; + # 启用期结构检查不得因没有伪造该业务值而拒绝目标 Schema。 + container.plugins.enable("mcp-runtime-schema") + container.plugins.put_setting_secret( + "mcp-runtime-schema", "api_key", "configured" + ) + with pytest.raises(ExtensionError) as exc: + run( + container.plugins.execute_command( + "mcp-runtime-schema.notify", + {"message": "must be rejected locally"}, + PluginCommandContext(selection="visible"), + ) + ) + assert exc.value.code == "PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH" + finally: + container.plugins.shutdown() + + def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None: async def scenario() -> None: mcp_container.plugins.enable("mcp-fixture") @@ -531,7 +691,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None: container = build_container() installed = container.plugins.install(MCP_FIXTURE) assert installed.status == "permission_required" - container.plugins.set_permissions("mcp-fixture", ["notes.read"]) + container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"]) try: with pytest.raises(ExtensionError) as exc: container.plugins.enable("mcp-fixture") @@ -565,7 +725,7 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) restarted = mcp_container.plugins.restart_host("mcp-fixture") assert restarted.status == "ready" - assert restarted.tools_count == 6 + assert restarted.tools_count == 7 assert mcp_container.tools.contains("mcp-fixture.echo") run(scenario()) diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py new file mode 100644 index 0000000..1235d6f --- /dev/null +++ b/backend/tests/test_plugin_contributions.py @@ -0,0 +1,653 @@ +import asyncio +import json +from pathlib import Path + +import pytest +from pydantic import TypeAdapter, ValidationError + +from app.agent import ToolRegistry +from app.config import BACKEND_DIR, get_settings +from app.container import build_container +from app.contracts import ( + PluginCommandContext, + PluginCommandEffect, + PluginNoEffect, + PluginSettingType, +) +from app.extensions import ExtensionError, PluginRuntime +from app.extensions.contributions import _secret_reference +from app.extensions.runtime import DeclarativePluginHost +from app.providers.credentials import CredentialStoreError + +TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools" + + +def run(coroutine): + return asyncio.run(coroutine) + + +def test_command_list_filter_and_lifecycle() -> None: + container = build_container() + + commands = container.plugins.list_commands() + palette = container.plugins.list_commands(location="command_palette") + + assert [item.command_id for item in commands] == ["text-tools.uppercase-selection"] + assert palette[0].plugin_id == "text-tools" + assert palette[0].icon == "edit" + assert palette[0].when == ["editor.has_selection"] + + container.plugins.disable("text-tools") + assert container.plugins.list_commands() == [] + with pytest.raises(ExtensionError) as exc: + run( + container.plugins.execute_command( + "text-tools.uppercase-selection", + {}, + PluginCommandContext(selection="hello"), + ) + ) + assert exc.value.code == "PLUGIN_COMMAND_NOT_FOUND" + + container.plugins.enable("text-tools") + assert len(container.plugins.list_commands()) == 1 + + +def test_command_executes_with_scoped_context_and_settings() -> None: + container = build_container() + container.plugins.update_settings("text-tools", 1, {"result_limit": 4}) + + result = run( + container.plugins.execute_command( + "text-tools.uppercase-selection", + {}, + PluginCommandContext( + vault_id="default", + note_id="note_private", + file_path="private.md", + selection="abcdef", + ), + ) + ) + + assert result.status == "completed" + assert result.effect.type == "notification" + assert result.effect.payload.model_dump() == { + "level": "success", + "message": "ABCD", + } + + +def test_echo_command_returns_none_for_empty_message() -> None: + host = DeclarativePluginHost() + + empty = run(host.execute_command("echo", {}, {}, {}, lambda _: None)) + populated = run( + host.execute_command("echo", {"message": "hello"}, {}, {}, lambda _: None) + ) + + assert isinstance(empty, PluginNoEffect) + assert populated.type == "notification" + assert populated.payload.message == "hello" + + +@pytest.mark.parametrize( + ("effect_type", "payload"), + [ + ("none", {"unexpected": True}), + ("notification", {"level": "debug", "message": "invalid"}), + ("navigate", {"route": "https://example.com"}), + ("refresh", {"scope": "everything"}), + ("job", {"job_id": "invalid job id"}), + ], +) +def test_command_effect_rejects_untrusted_payloads(effect_type, payload) -> None: + with pytest.raises(ValidationError): + TypeAdapter(PluginCommandEffect).validate_python( + {"type": effect_type, "payload": payload} + ) + + +def test_command_rejects_missing_context_and_invalid_arguments() -> None: + container = build_container() + + with pytest.raises(ExtensionError) as context_error: + run( + container.plugins.execute_command( + "text-tools.uppercase-selection", {}, PluginCommandContext() + ) + ) + assert context_error.value.code == "PLUGIN_COMMAND_CONTEXT_INVALID" + + with pytest.raises(ExtensionError) as argument_error: + run( + container.plugins.execute_command( + "text-tools.uppercase-selection", + {"unknown": True}, + PluginCommandContext(selection="hello"), + ) + ) + assert argument_error.value.code == "PLUGIN_COMMAND_ARGUMENT_INVALID" + + audit = container.plugins.commands.audit_events() + assert [event.error_code for event in audit[-2:]] == [ + "PLUGIN_COMMAND_CONTEXT_INVALID", + "PLUGIN_COMMAND_ARGUMENT_INVALID", + ] + # 审计事件不得携带参数、正文选区或返回 effect。 + assert "hello" not in repr(audit) + + +def test_command_only_receives_declared_context() -> None: + class CapturingHost(DeclarativePluginHost): + def __init__(self) -> None: + self.context = None + + async def execute_command( + self, handler, arguments, context, settings, resolve_secret + ): + self.context = context + return PluginNoEffect() + + host = CapturingHost() + runtime = PluginRuntime(ToolRegistry(), host=host) + runtime.install(TEXT_TOOLS) + runtime.enable("text-tools") + + run( + runtime.execute_command( + "text-tools.uppercase-selection", + {}, + PluginCommandContext( + vault_id="default", note_id="note_private", selection="visible" + ), + ) + ) + + assert host.context == {"selection": "visible"} + + +def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None: + class SecretHost(DeclarativePluginHost): + def __init__(self) -> None: + self.secret = None + self.denied_code = None + + async def execute_command( + self, handler, arguments, context, settings, resolve_secret + ): + self.secret = resolve_secret("api_key") + try: + resolve_secret("undeclared") + except ExtensionError as exc: + self.denied_code = exc.code + return PluginNoEffect() + + host = SecretHost() + runtime = PluginRuntime(ToolRegistry(), host=host) + package = tmp_path / "secret-command" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: secret-command +name: Secret Command +version: 1.0.0 +permissions: [secrets.use] +contributes: + commands: [secret-command.run] + settings_sections: [secret-command.general] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text( + """ +commands: + - command_id: secret-command.run + title: Secret Command + locations: [command_palette] + secrets: [api_key] + handler: echo +""".strip(), + encoding="utf-8", + ) + (package / "settings.yaml").write_text( + """ +section_id: secret-command.general +schema_version: 1 +fields: + - key: api_key + label: API Key + type: secret +""".strip(), + encoding="utf-8", + ) + runtime.install(package) + runtime.set_permissions("secret-command", ["secrets.use"]) + runtime.enable("secret-command") + runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret") + + run( + runtime.execute_command( + "secret-command.run", + {}, + PluginCommandContext(selection="visible"), + ) + ) + + assert host.secret == "runtime-only-secret" + assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED" + assert "runtime-only-secret" not in repr(runtime.commands.audit_events()) + + +def test_settings_schema_contains_defaults_and_hides_secret() -> None: + container = build_container() + + schema = container.plugins.get_settings("text-tools") + by_key = {field.key: field for field in schema.fields} + + assert schema.schema_version == 1 + assert schema.values == { + "result_limit": 100, + "label_prefix": "", + "output_style": "notification", + "enabled_hint": True, + } + assert "api_key" not in schema.values + assert schema.secrets["api_key"].configured is False + assert by_key["api_key"].type == PluginSettingType.secret + + +def test_settings_update_validates_version_type_bounds_and_secret_boundary() -> None: + container = build_container() + + updated = container.plugins.update_settings( + "text-tools", 1, {"result_limit": 20, "output_style": "compact"} + ) + assert updated.values["result_limit"] == 20 + assert updated.values["output_style"] == "compact" + + cases = [ + (2, {}, "PLUGIN_SETTINGS_VERSION_CONFLICT"), + (1, {"result_limit": 0}, "PLUGIN_SETTINGS_FIELD_INVALID"), + (1, {"enabled_hint": "yes"}, "PLUGIN_SETTINGS_FIELD_INVALID"), + (1, {"output_style": "unknown"}, "PLUGIN_SETTINGS_FIELD_INVALID"), + (1, {"api_key": "plaintext"}, "PLUGIN_SETTINGS_FIELD_INVALID"), + (1, {"unknown": True}, "PLUGIN_SETTINGS_FIELD_INVALID"), + ] + for version, values, code in cases: + with pytest.raises(ExtensionError) as exc: + container.plugins.update_settings("text-tools", version, values) + assert exc.value.code == code + + +def test_required_plain_setting_blocks_enable_until_configured(tmp_path: Path) -> None: + package = tmp_path / "required-setting" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: required-setting +name: Required Setting +version: 1.0.0 +contributes: + commands: [required-setting.run] + settings_sections: [required-setting.general] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text( + """ +commands: + - command_id: required-setting.run + title: Required Setting + locations: [command_palette] + handler: echo +""".strip(), + encoding="utf-8", + ) + (package / "settings.yaml").write_text( + """ +section_id: required-setting.general +schema_version: 1 +fields: + - key: endpoint + label: Endpoint + type: string + required: true +""".strip(), + encoding="utf-8", + ) + runtime = PluginRuntime(ToolRegistry()) + runtime.install(package) + + with pytest.raises(ExtensionError) as exc: + runtime.enable("required-setting") + assert exc.value.code == "PLUGIN_SETTINGS_REQUIRED" + assert runtime.get("required-setting").status == "installed" + + runtime.update_settings("required-setting", 1, {"endpoint": "local"}) + assert runtime.enable("required-setting").status == "ready" + + +def test_secret_roundtrip_never_enters_plain_settings_storage() -> None: + container = build_container() + plaintext = "stage-d-secret-value" + + status = container.plugins.put_setting_secret("text-tools", "api_key", plaintext) + schema = container.plugins.get_settings("text-tools") + settings_path = get_settings().data_dir / "plugins" / "settings.json" + credentials_path = get_settings().data_dir / "credentials" / "credentials.json" + + assert status.configured is True + assert schema.secrets["api_key"].configured is True + assert "api_key" not in schema.values + assert plaintext not in settings_path.read_text(encoding="utf-8") + assert plaintext not in credentials_path.read_text(encoding="utf-8") + stored_settings = json.loads(settings_path.read_text(encoding="utf-8")) + reference = stored_settings["text-tools"]["secret_refs"]["api_key"] + assert reference.startswith("plugin.") + assert len(reference) == 71 + assert "text-tools" not in reference and "api_key" not in reference + assert container.credentials.resolve(reference) == plaintext + + deleted = container.plugins.delete_setting_secret("text-tools", "api_key") + assert deleted.configured is False + assert container.credentials.resolve(reference) is None + + +def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None: + container = build_container() + container.plugins.update_settings("text-tools", 1, {"result_limit": 12}) + container.plugins.put_setting_secret("text-tools", "api_key", "temporary") + settings_path = get_settings().data_dir / "plugins" / "settings.json" + reference = json.loads(settings_path.read_text(encoding="utf-8"))[ + "text-tools" + ]["secret_refs"]["api_key"] + + container.plugins.uninstall("text-tools") + + stored = json.loads(settings_path.read_text(encoding="utf-8")) + assert "text-tools" not in stored + assert container.credentials.resolve(reference) is None + + +def test_plugin_secret_reference_has_fixed_credential_safe_length() -> None: + reference = _secret_reference("p" * 512, "k" * 128) + + assert reference.startswith("plugin.") + assert len(reference) <= 128 + + +def test_tampered_secret_reference_cannot_cross_credential_namespace() -> None: + container = build_container() + container.credentials.put("openai", "provider-private-secret") + settings_path = get_settings().data_dir / "plugins" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text( + json.dumps( + { + "text-tools": { + "schema_version": 1, + "values": {}, + "secret_refs": {"api_key": "openai"}, + } + } + ), + encoding="utf-8", + ) + + with pytest.raises(ExtensionError) as read_error: + container.plugins.get_settings("text-tools") + with pytest.raises(ExtensionError) as uninstall_error: + container.plugins.uninstall("text-tools") + + assert read_error.value.code == "PLUGIN_STORAGE_ERROR" + assert uninstall_error.value.code == "PLUGIN_STORAGE_ERROR" + assert container.credentials.resolve("openai") == "provider-private-secret" + + +def test_secret_delete_restores_reference_when_credential_delete_fails( + monkeypatch, +) -> None: + container = build_container() + container.plugins.put_setting_secret("text-tools", "api_key", "keep-me") + settings_path = get_settings().data_dir / "plugins" / "settings.json" + original = settings_path.read_text(encoding="utf-8") + reference = _secret_reference("text-tools", "api_key") + + def fail_delete(_credential_id: str) -> bool: + raise CredentialStoreError("injected delete failure") + + monkeypatch.setattr(container.credentials, "delete", fail_delete) + + with pytest.raises(ExtensionError) as exc: + container.plugins.delete_setting_secret("text-tools", "api_key") + + assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR" + assert settings_path.read_text(encoding="utf-8") == original + assert container.credentials.resolve(reference) == "keep-me" + + +def test_uninstall_restores_settings_when_atomic_secret_delete_fails( + monkeypatch, +) -> None: + container = build_container() + container.plugins.update_settings("text-tools", 1, {"result_limit": 12}) + container.plugins.put_setting_secret("text-tools", "api_key", "keep-me") + settings_path = get_settings().data_dir / "plugins" / "settings.json" + original = settings_path.read_text(encoding="utf-8") + reference = _secret_reference("text-tools", "api_key") + + def fail_delete_many(_credential_ids: list[str]) -> set[str]: + raise CredentialStoreError("injected batch delete failure") + + monkeypatch.setattr(container.credentials, "delete_many", fail_delete_many) + + with pytest.raises(ExtensionError) as exc: + container.plugins.uninstall("text-tools") + + assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR" + assert settings_path.read_text(encoding="utf-8") == original + assert container.credentials.resolve(reference) == "keep-me" + assert container.plugins.get("text-tools").manifest.plugin_id == "text-tools" + + +def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None: + invalid_command = tmp_path / "invalid-command" + invalid_command.mkdir() + (invalid_command / "plugin.yaml").write_text( + """ +id: invalid-command +name: Invalid Command +version: 1.0.0 +contributes: + commands: [other.run] +""".strip(), + encoding="utf-8", + ) + (invalid_command / "commands.yaml").write_text( + """ +commands: + - command_id: other.run + title: Invalid + locations: [command_palette] + handler: echo +""".strip(), + encoding="utf-8", + ) + + invalid_settings = tmp_path / "invalid-settings" + invalid_settings.mkdir() + (invalid_settings / "plugin.yaml").write_text( + """ +id: invalid-settings +name: Invalid Settings +version: 1.0.0 +contributes: + settings_sections: [invalid-settings.general] +""".strip(), + encoding="utf-8", + ) + (invalid_settings / "settings.yaml").write_text( + """ +section_id: invalid-settings.general +schema_version: 1 +fields: + - key: token + label: Token + type: secret + default: leaked-default +""".strip(), + encoding="utf-8", + ) + + runtime = PluginRuntime(ToolRegistry()) + with pytest.raises(ExtensionError) as command_error: + runtime.install(invalid_command) + assert command_error.value.code == "PLUGIN_COMMAND_INVALID" + + with pytest.raises(ExtensionError) as settings_error: + runtime.install(invalid_settings) + assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" + + +@pytest.mark.parametrize("bound", [".nan", ".inf", "-.inf"]) +def test_non_finite_setting_bounds_are_rejected(tmp_path: Path, bound: str) -> None: + package = tmp_path / f"invalid-bound-{bound.replace('.', 'dot').replace('-', 'neg')}" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: invalid-bound +name: Invalid Bound +version: 1.0.0 +contributes: + settings_sections: [invalid-bound.general] +backend: + type: none + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "settings.yaml").write_text( + f""" +section_id: invalid-bound.general +schema_version: 1 +fields: + - key: limit + label: Limit + type: number + minimum: {bound} +""".strip(), + encoding="utf-8", + ) + + with pytest.raises(ExtensionError) as exc: + PluginRuntime(ToolRegistry()).install(package) + + assert exc.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID" + assert "must be finite" in exc.value.message + + +def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None: + package = tmp_path / "null-commands" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: null-commands +name: Null Commands +version: 1.0.0 +contributes: + commands: [] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text("commands:\n", encoding="utf-8") + + with pytest.raises(ExtensionError) as exc: + PluginRuntime(ToolRegistry()).install(package) + + assert exc.value.code == "EXTENSION_MANIFEST_INVALID" + + +def test_external_command_schema_reference_is_rejected(tmp_path: Path) -> None: + package = tmp_path / "external-ref" + package.mkdir() + (package / "plugin.yaml").write_text( + """ +id: external-ref +name: External Ref +version: 1.0.0 +contributes: + commands: [external-ref.run] +backend: + type: internal_rpc + transport: none +""".strip(), + encoding="utf-8", + ) + (package / "commands.yaml").write_text( + """ +commands: + - command_id: external-ref.run + title: External Ref + locations: [command_palette] + handler: echo + parameters: + $ref: file:///host/private-schema.json +""".strip(), + encoding="utf-8", + ) + + with pytest.raises(ExtensionError) as exc: + PluginRuntime(ToolRegistry()).install(package) + + assert exc.value.code == "PLUGIN_COMMAND_INVALID" + assert "External JSON Schema reference" in exc.value.message + + +def test_settings_missing_and_secret_field_errors_are_stable() -> None: + container = build_container() + + with pytest.raises(ExtensionError) as missing: + container.plugins.get_settings("does-not-exist") + assert missing.value.code == "PLUGIN_NOT_FOUND" + + with pytest.raises(ExtensionError) as field: + container.plugins.put_setting_secret("text-tools", "result_limit", "secret") + assert field.value.code == "PLUGIN_SECRET_FIELD_NOT_FOUND" + + with pytest.raises(ExtensionError) as empty: + container.plugins.put_setting_secret("text-tools", "api_key", "") + assert empty.value.code == "PLUGIN_SECRET_VALUE_INVALID" + + +def test_corrupted_plugin_settings_namespace_returns_stable_error() -> None: + container = build_container() + settings_path = get_settings().data_dir / "plugins" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text('{"text-tools": []}', encoding="utf-8") + + with pytest.raises(ExtensionError) as exc: + container.plugins.get_settings("text-tools") + + assert exc.value.code == "PLUGIN_STORAGE_ERROR" + + with pytest.raises(ExtensionError) as secret_exc: + container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan") + + assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR" + credentials_path = get_settings().data_dir / "credentials" / "credentials.json" + credential_ids = ( + json.loads(credentials_path.read_text(encoding="utf-8")).keys() + if credentials_path.exists() + else [] + ) + assert not any(item.startswith("plugin.") for item in credential_ids) diff --git a/backend/tests/test_schema_security.py b/backend/tests/test_schema_security.py new file mode 100644 index 0000000..cac9412 --- /dev/null +++ b/backend/tests/test_schema_security.py @@ -0,0 +1,65 @@ +import pytest + +from app.schema_security import ( + ExternalSchemaReferenceError, + UnresolvableLocalSchemaReferenceError, + reject_external_schema_references, +) + + +@pytest.mark.parametrize( + "schema", + [ + {"$ref": "file:///host/private-schema.json"}, + {"properties": {"value": {"$ref": "https://schema.invalid/value.json"}}}, + {"allOf": [{"$dynamicRef": "https://schema.invalid/dynamic"}]}, + ], +) +def test_external_json_schema_references_are_rejected(schema) -> None: + with pytest.raises(ExternalSchemaReferenceError): + reject_external_schema_references(schema) + + +def test_local_json_schema_fragment_reference_is_allowed() -> None: + reject_external_schema_references( + { + "$defs": {"value": {"type": "string"}}, + "properties": {"value": {"$ref": "#/$defs/value"}}, + } + ) + + +@pytest.mark.parametrize("reference", ["#/$defs/missing", "#missing-anchor"]) +def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None: + with pytest.raises(UnresolvableLocalSchemaReferenceError): + reject_external_schema_references({"type": "object", "$ref": reference}) + + +def test_root_reference_cannot_use_anchor_from_nested_schema_resource() -> None: + schema = { + "$defs": { + "nested": { + "$id": "nested", + "$anchor": "inside", + "type": "string", + } + }, + "properties": {"value": {"$ref": "#inside"}}, + } + + with pytest.raises(UnresolvableLocalSchemaReferenceError): + reject_external_schema_references(schema) + + +def test_nested_schema_resource_can_resolve_its_own_anchor() -> None: + schema = { + "$defs": { + "nested": { + "$id": "nested", + "$anchor": "inside", + "allOf": [{"$ref": "#inside"}], + } + } + } + + reject_external_schema_references(schema) diff --git a/backend/uv.lock b/backend/uv.lock index 03432b5..16eefd2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -374,6 +374,7 @@ dependencies = [ { name = "httpx" }, { name = "jsonschema" }, { name = "pyyaml" }, + { name = "referencing" }, { name = "sqlite-vec" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -390,6 +391,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28,<1.0" }, { name = "jsonschema", specifier = ">=4.25,<5.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, + { name = "referencing", specifier = ">=0.36,<1.0" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" }, ] diff --git a/docs/README.md b/docs/README.md index 00e38a5..8625a06 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ - [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md) - [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md) - [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md) +- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md) - [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md) - [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md) - [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md) diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index 47b6fe7..80a321b 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -5,7 +5,7 @@ > 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步 > 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员 -> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已完成。后续继续接入真实音频处理、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 +> 实施状态更新:2026-09-02。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成。后续继续接入真实音频处理、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 --- @@ -2329,7 +2329,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 也已完成。当前验证基线为后端 126 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md index 7fafabf..0e229b5 100644 --- a/docs/architecture/第二阶段团队分工表.md +++ b/docs/architecture/第二阶段团队分工表.md @@ -748,8 +748,8 @@ Markdown - [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript; - [x] MCP Server 能通过 MCP Bridge 注册 Tool; - [x] Agent 能调用 MCP Tool; -- [ ] Plugin Command Contribution 后端可注册; -- [ ] Plugin Settings Contribution 后端可解析; +- [x] Plugin Command Contribution 后端可注册; +- [x] Plugin Settings Contribution 后端可解析; - [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定; - [ ] 完成跨模块接口审阅和第二阶段集成。 diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index 099a666..cc1abc9 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -176,7 +176,7 @@ RunCancelled ## 当前实现状态 -更新至 2026-09-01:后端 92 项回归测试通过。 +更新至 2026-09-02:后端 126 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 68e0d9b..00d3adb 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -47,11 +47,11 @@ | Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 | | Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 | | Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool | -| Plugin Command | GET | `/api/plugin-contributions/commands` | 计划新增 | 获取前端可展示的 Command | -| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 计划新增 | 受控执行 Command | -| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 计划新增 | 获取 Schema 与非敏感配置 | -| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 计划新增 | 更新非敏感配置 | -| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 计划新增 | 写入或删除 Secret Reference | +| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command | +| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command | +| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 | +| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 已实现 | 更新非敏感配置 | +| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 已实现 | 写入或删除 Secret Reference | | Provider | 现有路径 | `/api/providers/*`、`POST /api/chat` | 扩展 | 补齐协议能力和统一行为 | | Retrieval | GET/POST | `/api/index/status`、`/api/index/rebuild` | 扩展 | 暴露 Embedding 兼容状态并安全重建向量 | | Benchmark | GET | `/api/benchmarks/datasets` | 计划新增 | 枚举受控 Dataset | @@ -540,7 +540,13 @@ error } ``` -允许的 effect 首批为 `none`、`notification`、`navigate`、`refresh` 和 `job`。前端仅执行白名单 effect;未知类型显示结果但不执行。 +允许的 effect 首批为 `none`、`notification`、`navigate`、`refresh` 和 `job`。每类 payload 也是契约的一部分:`none` 必须为空;`notification` 只接受 `level`(`info/success/warning/error`)和非空 `message`;`navigate` 只接受宿主路由名 `route`;`refresh` 只接受 `workspace/commands/settings/plugins` 范围;`job` 只接受受限格式的 `job_id`。后端拒绝未知字段和不匹配的 payload,前端仍须按判别联合穷尽处理,不得把 effect 当作任意代码执行。 + +当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。 + +需要 Secret 的 Command 必须在 `commands.yaml` 的内部 `secrets` 数组中声明对应 Setting Key,并在 Plugin Manifest 声明 `secrets.use` 权限。安装时宿主校验该字段确实属于当前 Plugin Settings Schema 的 `secret` 类型;只有权限已授予并启用后,运行时才向受控 handler 或 MCP Command Target 提供声明过的 Secret。未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置返回 `PLUGIN_SECRET_REQUIRED`。`secrets` 不属于前端 `PluginCommand` DTO,Secret 明文也不会并入普通 Settings 字典。 + +`commands.yaml` 中的执行目标必须在宿主白名单 `handler` 与当前 Plugin 命名空间的 `mcp_tool` 之间二选一。MCP Command Target 不注册为 Agent Tool;宿主用 `_notesagent` 保留包装传入 Command ID、参数、裁剪后的 Context、已校验的非敏感 Settings 和声明过的 Secret,并将 MCP structured result 再校验为白名单 effect。作为宿主协议标记,目标 MCP Tool 的 `inputSchema` 必须在顶层 `properties` 中直接声明 `_notesagent: { type: object }`;不得用顶层组合或引用替代该标记。`_notesagent` 对象内部仍可使用完整 Draft 2020-12 约束、文档内引用和组合 Schema。启用阶段只检查协议标记,不尝试求解 Schema 或伪造业务值;宿主会保留完整 Schema,并在每次调用前用官方 Validator 校验真实信封。Command 与 Tool Schema 仅允许 `#...` 文档内引用,任何通过 `$ref` 或 `$dynamicRef` 指向文件、HTTP 或其他外部资源的 Schema 都会在注册前被拒绝。文档内引用遵循 Draft 2020-12 的嵌套 `$id` 与 Anchor 资源作用域,不能解析的引用不得进入运行时。 ### 7.5 Settings Schema @@ -585,6 +591,8 @@ select secret ``` +Number 字段的 `minimum` 和 `maximum` 必须是有限数值;`NaN`、正无穷和负无穷均视为无效 Settings Schema。 + ### 7.6 更新 Settings 和 Secret `PUT /api/plugins/{plugin_id}/settings` @@ -596,7 +604,7 @@ secret } ``` -该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。 +该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。没有默认值的必填普通字段必须先通过该接口配置;否则 Plugin Enable 和 Command Execute 返回 `PLUGIN_SETTINGS_REQUIRED`,MCP 或内部 handler 不会收到残缺配置。 Secret 使用: @@ -623,6 +631,10 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret Secret 明文不进入普通 Settings、日志、Trace、Benchmark Dataset 或前端持久化。 +非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的定长 Secret Reference,格式为 `plugin.`;Secret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。 + +`plugin.*` 为宿主保留凭据命名空间。`/api/credentials/{credential_id}`、Provider 持久配置、Provider 临时测试凭据和 Provider Resolver 均拒绝该前缀,防止通过 Provider 链路覆盖、删除或向外部 Base URL 发送 Plugin Secret。 + ### 7.7 Plugin/MCP 错误码 ```text @@ -636,10 +648,26 @@ MCP_TOOL_CALL_FAILED MCP_TOOL_RESULT_TOO_LARGE MCP_TRUST_APPROVAL_REQUIRED PLUGIN_COMMAND_NOT_FOUND +PLUGIN_COMMAND_CONFLICT +PLUGIN_COMMAND_INVALID +PLUGIN_COMMAND_ARGUMENT_INVALID PLUGIN_COMMAND_CONTEXT_INVALID +PLUGIN_COMMAND_TIMEOUT +PLUGIN_COMMAND_EXECUTION_FAILED +PLUGIN_COMMAND_RESULT_INVALID +PLUGIN_COMMAND_RESULT_TOO_LARGE +PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH PLUGIN_SETTINGS_SCHEMA_INVALID PLUGIN_SETTINGS_VERSION_CONFLICT +PLUGIN_SETTINGS_FIELD_INVALID +PLUGIN_SETTINGS_REQUIRED PLUGIN_SECRET_FIELD_NOT_FOUND +PLUGIN_SECRET_ACCESS_DENIED +PLUGIN_SECRET_REQUIRED +PLUGIN_SECRET_VALUE_INVALID +PLUGIN_SECRET_STORE_ERROR +PLUGIN_STORAGE_ERROR +CREDENTIAL_NAMESPACE_RESERVED ``` --- diff --git a/docs/development/AI-Core与Agent-Core开发说明.md b/docs/development/AI-Core与Agent-Core开发说明.md index fc79e05..4e4adfe 100644 --- a/docs/development/AI-Core与Agent-Core开发说明.md +++ b/docs/development/AI-Core与Agent-Core开发说明.md @@ -2,7 +2,7 @@ > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 -> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。 +> 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 126 项测试通过。 ## 当前实现 @@ -68,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider - Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供; - FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供; - 文件系统和 API Key 明文读取:由 Rust Host 提供; -- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。 +- Frontend Extension Slot 与 Plugin Command/Settings UI:后端 Contract 与前端 Service 已完成,页面由前端后续联调。 ## Provider @@ -349,4 +349,4 @@ Skill Manifest - Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。 - `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。 - Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。 -- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP Server;Streamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。 +- 当前 Plugin Host 支持内置声明式 handler、本地 stdio MCP Server 以及 Plugin Command/Settings;Streamable HTTP、OS 级沙箱与 UI Contribution 留在后续阶段。 diff --git a/docs/development/Knowledge与Retrieval-Core开发说明.md b/docs/development/Knowledge与Retrieval-Core开发说明.md index 8aee584..f8b7ed1 100644 --- a/docs/development/Knowledge与Retrieval-Core开发说明.md +++ b/docs/development/Knowledge与Retrieval-Core开发说明.md @@ -3,7 +3,7 @@ > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 -> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。 +> 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 126 项测试通过。 ## 当前实现 diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md index bd43a94..b39245c 100644 --- a/docs/development/MCP-Bridge与Plugin-Host开发说明.md +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -1,6 +1,6 @@ # MCP Bridge 与 Plugin Host 开发说明 -> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。 +> 更新日期:2026-09-02。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。阶段 D 的 Plugin Command 与 Settings 已在其独立开发说明中落地。 ## 1. 目标与实现状态 @@ -235,6 +235,7 @@ backend/extensions/fixtures/mcp-echo - `mcp-fixture.large`:验证结果大小上限; - `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程; - `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。 +- `mcp-fixture.command`:作为 Plugin Command 专用 MCP Target,验证 Context 裁剪、Secret 传递和与 Agent Tool 的隔离。 Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。 @@ -279,4 +280,4 @@ pnpm build - 一键安装前的完整命令展示与确认 UI; - Tool 列表热更新的无中断替换。 -阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。 +阶段 D 已在当前 Plugin Runtime 上增加 Command、Settings、Secret Contract 和命名空间 Storage,且未修改 Agent 使用内部 Tool Contract 的原则。实现细节见《Plugin Command 与 Settings 开发说明》。 diff --git a/docs/development/Plugin-Command与Settings开发说明.md b/docs/development/Plugin-Command与Settings开发说明.md new file mode 100644 index 0000000..1285b16 --- /dev/null +++ b/docs/development/Plugin-Command与Settings开发说明.md @@ -0,0 +1,115 @@ +# Plugin Command 与 Settings 开发说明 + +> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 126 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。 + +## 1. 阶段目标 + +阶段 D 在阶段 C 的 Plugin Runtime 与隔离 MCP Host 上补齐两类宿主贡献: + +- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect; +- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用; +- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调。 + +本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。 + +## 2. 包内声明 + +Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_sections` 声明贡献标识,并分别提供 `commands.yaml`、`settings.yaml`。安装时宿主要求声明集合与文件内容完全一致,拒绝重复项、越过 Plugin 命名空间的 ID、未声明权限和无效 Schema。 + +`commands.yaml` 的首批字段包括: + +- `command_id`、标题、描述、宿主图标; +- `locations`:`command_palette`、`context_menu` 或 `toolbar`; +- `when` 与允许传入执行器的 Context 字段; +- 参数 JSON Schema、可选权限、执行目标和超时; +- 执行目标必须在宿主白名单 `handler` 与当前插件命名空间的 `mcp_tool` 之间二选一。 +- 可选 `secrets` 字段:只声明当前 Command 允许按需读取的 Secret Setting Key,不暴露给前端 DTO。 + +`settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string`、`number`、`boolean`、`select`、`secret`。宿主会校验默认值、必填项、有限数值边界、Select 选项,以及 Secret 不得携带默认明文;`NaN` 与正负无穷不能用作上下界。 + +仓库内 `text-tools` 是联调 Fixture,覆盖 Command 和五种 Settings 字段类型。 + +## 3. Command 运行链路 + +`CommandRegistry` 只发布处于启用状态的 Plugin Command。Plugin 禁用、Host 不可用或重启时,Command 与 Tool 使用同样的注销/重新注册生命周期,避免前端看到实际不可执行的命令。 + +执行顺序如下: + +1. 查找已注册 Command; +2. 使用 Draft 2020-12 JSON Schema 校验 arguments; +3. 根据 `when` 检查必要上下文; +4. 仅向执行器传递声明过的 Context 字段; +5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target; +6. 按 effect 类型校验专属 payload、可序列化性和 64 KiB 大小上限; +7. 返回统一 `PluginCommandResult`。 + +首批 effect 为 `none`、`notification`、`navigate`、`refresh` 和 `job`。后端分别限制通知级别与消息、宿主路由名、刷新范围和 Job ID;Pydantic 与 TypeScript 均使用同一判别语义,前端不得把 effect 当作任意代码执行。 + +Command 执行器通过受控 Resolver 按需读取 `commands.yaml` 已声明且确实属于当前 Plugin Schema 的 Secret;使用 Secret 的 Plugin 还必须声明并获授 `secrets.use` 权限。读取未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置则返回 `PLUGIN_SECRET_REQUIRED`。Secret 不会并入普通 Settings 字典。Command 审计使用 500 条有界内存队列,仅保留 `command_id`、`plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。 + +MCP Command Target 是专用执行目标,不注册进 Agent `ToolRegistry`,因此模型无法绕过 Command 权限与 Context 裁剪直接调用。宿主通过 `_notesagent` 保留包装传入 `command_id`、已校验 arguments、已裁剪 Context、已校验的非敏感 Settings 和声明过的 Secret。启用阶段只检查目标 `inputSchema` 在顶层 `properties` 中直接声明 `_notesagent: { type: object }`,不自行求解 JSON Schema,也不用空对象伪造业务数据;引用和组合约束可以放在 `_notesagent` 对象内部。执行阶段再用保留的完整 Schema 和官方 Draft 2020-12 Validator 校验真实信封。MCP Server 必须返回结构化的白名单 effect。远程原始错误不直接透传给 HTTP 调用方。插件仍不能把模块路径或 Shell 字符串作为执行器。 + +Command 与 Tool 的 JSON Schema 只允许当前文档内的 Fragment 引用(`#...`);宿主在注册前递归拒绝 `$ref` / `$dynamicRef` 指向的文件、HTTP 或其他外部资源,避免 Schema 校验触发未授权 I/O。文档内引用使用 Draft 2020-12 Resource Resolver 预检,嵌套 `$id` 创建的新资源及其 Anchor 按各自作用域解析,无法解析的引用在注册阶段返回稳定错误。 + +## 4. Settings 与 Secret 边界 + +普通 Settings 以 Plugin 为命名空间持久化到: + +```text +APP_DATA_DIR/plugins/settings.json +``` + +该文件只包含: + +- 当前 Schema 版本; +- 非敏感字段值; +- Secret 的确定性定长引用,格式为 `plugin.`。 + +Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。`plugin.*` 是保留命名空间,通用凭据 API、Provider 配置、Provider 临时测试凭据和 Provider Resolver 均不得访问,防止覆盖、删除或外发 Plugin Secret。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。 + +没有默认值的 `required` 普通字段必须在启用 Plugin 前配置。Enable 和每次 Command Execute 都会重新检查有效设置;缺失时返回 `PLUGIN_SETTINGS_REQUIRED`,不启动 MCP Host,也不调用 Command handler。 + +读取持久化引用时,宿主会重新计算并核对 `plugin.`,引用不匹配即按损坏存储拒绝处理,不能借由篡改 `settings.json` 读取或删除 Provider 等其他命名空间的凭据。删除单个 Secret 或卸载 Plugin 时先原子更新 Settings 引用,再删除加密凭据;底层删除失败会恢复原引用。多 Secret 卸载使用一次凭据表原子替换,避免分批删除部分删除。 + +开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 Keychain,HTTP Contract 无需因此改变。 + +## 5. HTTP 与前端 Service + +后端已实现: + +```text +GET /api/plugin-contributions/commands?location=command_palette +POST /api/plugin-contributions/commands/{command_id}/execute +GET /api/plugins/{plugin_id}/settings +PUT /api/plugins/{plugin_id}/settings +PUT /api/plugins/{plugin_id}/settings/{key}/secret +DELETE /api/plugins/{plugin_id}/settings/{key}/secret +``` + +前端 `pluginService` 已提供对应方法及 Wire DTO,但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志。 + +## 6. 主要错误边界 + +- Command 未注册、冲突、参数或 Context 无效; +- 执行超时、执行器异常、effect 无效或过大; +- Settings Schema 无效、版本冲突、字段类型/边界错误或运行时必填值缺失; +- Secret 字段不存在、空 Secret、凭据存储异常; +- Settings JSON 根结构或 Plugin 命名空间损坏。 + +以上错误统一转换为 `ExtensionError` 和稳定业务错误码,HTTP 层不暴露内部堆栈、Secret 或插件返回的原始异常。 + +## 7. 验证 + +```powershell +cd backend +uv run pytest + +cd ../frontend +pnpm test -- --run +pnpm type-check +pnpm build +``` + +阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。 + +生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。 diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index 40a5dd3..7368ab2 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -186,13 +186,13 @@ pnpm build ```text pnpm build passed -pnpm test 27 passed -uv run pytest 92 passed +pnpm test 29 passed +uv run pytest 126 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 126 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/development/模型提供商与模型发现开发说明.md b/docs/development/模型提供商与模型发现开发说明.md index 598ede8..ea9547f 100644 --- a/docs/development/模型提供商与模型发现开发说明.md +++ b/docs/development/模型提供商与模型发现开发说明.md @@ -104,4 +104,4 @@ pnpm build 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 -当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 +当前完整回归基线:后端 126 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。`plugin.*` 为 Plugin Secret 保留命名空间,Provider 配置、临时测试凭据和通用凭据 API 均拒绝该前缀。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 diff --git a/docs/retrospectives/后端全面审阅问题与修复复盘.md b/docs/retrospectives/后端全面审阅问题与修复复盘.md index 59fa47e..370bc7c 100644 --- a/docs/retrospectives/后端全面审阅问题与修复复盘.md +++ b/docs/retrospectives/后端全面审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 -> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。 +> 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化、stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 126 项测试通过。 ## 1. 审阅结论 diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 8891885..901cf43 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -271,6 +271,86 @@ export interface PluginHostStatus { error?: string | null } +export type PluginCommandLocation = 'command_palette' | 'context_menu' | 'toolbar' + +export interface PluginCommand { + command_id: string + plugin_id: string + title: string + description: string + icon?: string | null + locations: PluginCommandLocation[] + when: string[] + parameters: Record + enabled: boolean +} + +export interface PluginCommandContext { + vault_id?: string | null + note_id?: string | null + file_path?: string | null + selection?: string | null +} + +export type PluginCommandEffect = + | { type: 'none'; payload: Record } + | { + type: 'notification' + payload: { level: 'info' | 'success' | 'warning' | 'error'; message: string } + } + | { + type: 'navigate' + payload: { + route: + | 'vault-entry' + | 'workspace' + | 'search' + | 'chat' + | 'agent' + | 'tasks' + | 'skills' + | 'plugins' + | 'themes' + | 'settings' + } + } + | { type: 'refresh'; payload: { scope: 'workspace' | 'commands' | 'settings' | 'plugins' } } + | { type: 'job'; payload: { job_id: string } } + +export interface PluginCommandResult { + command_id: string + status: 'completed' + effect: PluginCommandEffect +} + +export type PluginSettingType = 'string' | 'number' | 'boolean' | 'select' | 'secret' + +export interface PluginSettingField { + key: string + label: string + description: string + type: PluginSettingType + required: boolean + default?: unknown + minimum?: number | null + maximum?: number | null + options: string[] +} + +export interface PluginSettingsSchema { + plugin_id: string + schema_version: number + fields: PluginSettingField[] + values: Record + secrets: Record +} + +export interface PluginSecretStatus { + plugin_id: string + key: string + configured: boolean +} + export interface PluginContribution { type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section' id: string diff --git a/frontend/src/services/pluginService.spec.ts b/frontend/src/services/pluginService.spec.ts new file mode 100644 index 0000000..8239921 --- /dev/null +++ b/frontend/src/services/pluginService.spec.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as pluginService from './pluginService' + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('pluginService contribution adapter', () => { + it('lists and executes Plugin Commands with scoped wire fields', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(jsonResponse({ items: [{ command_id: 'text-tools.uppercase-selection' }] })) + .mockResolvedValueOnce(jsonResponse({ + command_id: 'text-tools.uppercase-selection', + status: 'completed', + effect: { type: 'notification', payload: { level: 'success', message: 'HELLO' } }, + })) + + const commands = await pluginService.listPluginCommands('command_palette') + const result = await pluginService.executePluginCommand( + 'text-tools.uppercase-selection', + {}, + { note_id: 'note-1', selection: 'hello' }, + ) + + expect(commands[0].command_id).toBe('text-tools.uppercase-selection') + if (result.effect.type !== 'notification') throw new Error('expected notification effect') + expect(result.effect.payload.message).toBe('HELLO') + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/plugin-contributions/commands?location=command_palette', + ) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + arguments: {}, + context: { note_id: 'note-1', selection: 'hello' }, + }) + }) + + it('uses separate Settings and Secret endpoints', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(jsonResponse({ + plugin_id: 'text-tools', schema_version: 1, fields: [], + values: { result_limit: 10 }, secrets: { api_key: { configured: false } }, + })) + .mockResolvedValueOnce(jsonResponse({ + plugin_id: 'text-tools', schema_version: 1, fields: [], + values: { result_limit: 20 }, secrets: { api_key: { configured: false } }, + })) + .mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: true })) + .mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: false })) + + await pluginService.getPluginSettings('text-tools') + await pluginService.updatePluginSettings('text-tools', 1, { result_limit: 20 }) + await pluginService.putPluginSecret('text-tools', 'api_key', 'request-only-secret') + await pluginService.deletePluginSecret('text-tools', 'api_key') + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + '/api/plugins/text-tools/settings', + '/api/plugins/text-tools/settings', + '/api/plugins/text-tools/settings/api_key/secret', + '/api/plugins/text-tools/settings/api_key/secret', + ]) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + schema_version: 1, + values: { result_limit: 20 }, + }) + expect(JSON.parse(String(fetchMock.mock.calls[2][1]?.body))).toEqual({ + secret: 'request-only-secret', + }) + expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE') + }) +}) diff --git a/frontend/src/services/pluginService.ts b/frontend/src/services/pluginService.ts index 48b6a28..e9ec869 100644 --- a/frontend/src/services/pluginService.ts +++ b/frontend/src/services/pluginService.ts @@ -1,5 +1,17 @@ import apiClient from './apiClient' -import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts' +import type { + ApiPlugin, + OperationResponse, + Plugin, + PluginCommand, + PluginCommandContext, + PluginCommandLocation, + PluginCommandResult, + PluginContribution, + PluginHostStatus, + PluginSecretStatus, + PluginSettingsSchema, +} from '@/contracts' function toPlugin(plugin: ApiPlugin): Plugin { const { manifest } = plugin @@ -62,6 +74,55 @@ export async function restartPluginHost(pluginId: string): Promise { + const query = location ? `?location=${encodeURIComponent(location)}` : '' + const response = await apiClient.get<{ items: PluginCommand[] }>(`/api/plugin-contributions/commands${query}`) + return response.items +} + +export async function executePluginCommand( + commandId: string, + argumentsValue: Record = {}, + context: PluginCommandContext = {}, +): Promise { + return apiClient.post(`/api/plugin-contributions/commands/${encodeURIComponent(commandId)}/execute`, { + arguments: argumentsValue, + context, + }) +} + +export async function getPluginSettings(pluginId: string): Promise { + return apiClient.get(`/api/plugins/${encodeURIComponent(pluginId)}/settings`) +} + +export async function updatePluginSettings( + pluginId: string, + schemaVersion: number, + values: Record, +): Promise { + return apiClient.put(`/api/plugins/${encodeURIComponent(pluginId)}/settings`, { + schema_version: schemaVersion, + values, + }) +} + +export async function putPluginSecret( + pluginId: string, + key: string, + secret: string, +): Promise { + return apiClient.put( + `/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`, + { secret }, + ) +} + +export async function deletePluginSecret(pluginId: string, key: string): Promise { + return apiClient.delete( + `/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`, + ) +} + export async function uninstallPlugin(pluginId: string): Promise { return apiClient.delete(`/api/plugins/${pluginId}`) }