From 0a9cad1c7691d4134be98b368ae9430618f1e061 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 2 Sep 2026 12:52:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(extension):=20=E5=AE=9E=E7=8E=B0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=91=BD=E4=BB=A4=E4=B8=8E=E8=AE=BE=E7=BD=AE=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- backend/README.md | 4 +- backend/app/container.py | 1 + backend/app/contracts.py | 92 +++ backend/app/extensions/__init__.py | 8 +- backend/app/extensions/contributions.py | 749 ++++++++++++++++++ backend/app/extensions/errors.py | 21 + backend/app/extensions/runtime.py | 233 +++++- backend/app/routes.py | 88 ++ .../plugins/text-tools/commands.yaml | 19 + .../extensions/plugins/text-tools/plugin.yaml | 4 + .../plugins/text-tools/settings.yaml | 31 + backend/tests/test_api.py | 4 + backend/tests/test_plugin_contributions.py | 296 +++++++ frontend/src/contracts/index.ts | 60 ++ frontend/src/services/pluginService.spec.ts | 84 ++ frontend/src/services/pluginService.ts | 63 +- 17 files changed, 1734 insertions(+), 26 deletions(-) create mode 100644 backend/app/extensions/contributions.py create mode 100644 backend/app/extensions/errors.py create mode 100644 backend/extensions/plugins/text-tools/commands.yaml create mode 100644 backend/extensions/plugins/text-tools/settings.yaml create mode 100644 backend/tests/test_plugin_contributions.py create mode 100644 frontend/src/services/pluginService.spec.ts diff --git a/README.md b/README.md index 10d735b..aa77036 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 103 项测试、前端 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..b1a3bec 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_` 注入;不要把真实密钥写入仓库。 +当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 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..e0d4869 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -486,6 +486,98 @@ 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 PluginCommandEffect(Contract): + type: Literal["none", "notification", "navigate", "refresh", "job"] = "none" + payload: dict[str, Any] = Field(default_factory=dict) + + +class PluginCommandResult(Contract): + command_id: str + status: Literal["completed"] = "completed" + effect: PluginCommandEffect = Field(default_factory=PluginCommandEffect) + + +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..6a17a38 --- /dev/null +++ b/backend/app/extensions/contributions.py @@ -0,0 +1,749 @@ +"""Plugin Command Registry 与 Settings/Secret 命名空间存储。""" + +from __future__ import annotations + +import asyncio +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 + +from app.config import get_settings +from app.contracts import ( + PluginCommand, + PluginCommandContext, + PluginCommandEffect, + PluginCommandLocation, + PluginCommandResult, + PluginSecretState, + PluginSecretStatus, + PluginSettingField, + PluginSettingType, + PluginSettingsSchema, +) +from app.extensions.errors import ExtensionError +from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore + +_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 + handler: Literal["echo", "uppercase_selection"] + timeout_seconds: int = Field(default=30, ge=1, le=120) + + +CommandExecutor = Callable[ + [dict[str, Any], dict[str, Any]], + PluginCommandEffect | Awaitable[PluginCommandEffect], +] + + +@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, PluginCommandEffect): + 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) + 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 = secret_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 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) + 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) + reference = refs.pop(key, None) + if reference: + try: + self.credentials.delete(reference) + except CredentialStoreError as exc: + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + if plugin_id in data: + self._write(data) + 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 = 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) + if isinstance(entry, dict): + refs = entry.get("secret_refs", {}) + if isinstance(refs, dict): + for reference in refs.values(): + if isinstance(reference, str): + try: + self.credentials.delete(reference) + except CredentialStoreError as exc: + raise ExtensionError( + "PLUGIN_SECRET_STORE_ERROR", + str(exc), + status_code=500, + ) from exc + if entry is not None: + self._write(data) + + 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() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + try: + temporary.write_text( + json.dumps(value, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + temporary.replace(path) + except OSError as exc: + 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 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.", + ) + 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) + except SchemaError as exc: + raise ExtensionError( + "PLUGIN_COMMAND_INVALID", + f"Plugin command parameters contain invalid JSON Schema: {exc.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: + return f"plugin.{plugin_id}.{key}" + + +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/runtime.py b/backend/app/extensions/runtime.py index 832b726..054424e 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -16,8 +16,15 @@ from app.agent.permissions import KNOWN_PERMISSIONS from app.contracts import ( ModelCapability, Plugin, + PluginCommand, + PluginCommandContext, + PluginCommandEffect, + PluginCommandLocation, + PluginCommandResult, PluginManifest, PluginHostStatus, + PluginSecretStatus, + PluginSettingsSchema, PluginStatus, RetrievalConfig, Skill, @@ -25,27 +32,21 @@ from app.contracts import ( SkillStatus, ToolDefinition, ) +from app.extensions.contributions import ( + CommandRegistry, + PluginCommandSpec, + 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 _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,13 +239,42 @@ 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], + ) -> PluginCommandEffect: + """执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。""" + + if handler == "echo": + message = str(arguments.get("message", context.get("selection", ""))) + return PluginCommandEffect( + type="notification", + 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 PluginCommandEffect( + type="notification", + 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] @@ -256,12 +286,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 +320,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 +340,47 @@ 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) record = _PluginRecord( plugin=Plugin( @@ -316,8 +392,11 @@ class PluginRuntime: ), ), tools=specs, + commands=command_specs, + settings_definition=settings_definition, package_path=root, registered_tools=[], + registered_commands=[], mcp_remote_names={}, ) self._records[manifest.plugin_id] = record @@ -368,6 +447,16 @@ 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": @@ -405,11 +494,36 @@ 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: + settings = ( + self.settings.get( + _record.plugin.manifest.plugin_id, + _record.settings_definition, + ).values + if _record.settings_definition is not None + else {} + ) + return await self.host.execute_command( + _spec.handler, arguments, context, settings + ) + + 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() self.mcp.stop(plugin_id) record.plugin.status = PluginStatus.error @@ -468,6 +582,9 @@ 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() if record.plugin.manifest.backend.type == "mcp": self.mcp.stop(plugin_id) @@ -479,6 +596,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,6 +660,9 @@ 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() self.mcp.stop(plugin_id) record.plugin.enabled = False @@ -567,6 +724,9 @@ 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.plugin.enabled = False record.plugin.status = PluginStatus.error @@ -594,6 +754,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 +776,46 @@ 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) + try: + return [ + PluginCommandSpec.model_validate(item) + for item in raw.get("commands", []) + ] + 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() diff --git a/backend/app/routes.py b/backend/app/routes.py index 201973e..98a5f94 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, @@ -559,6 +567,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}", 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/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_plugin_contributions.py b/backend/tests/test_plugin_contributions.py new file mode 100644 index 0000000..d152beb --- /dev/null +++ b/backend/tests/test_plugin_contributions.py @@ -0,0 +1,296 @@ +import asyncio +import json +from pathlib import Path + +import pytest + +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, + PluginSettingType, +) +from app.extensions import ExtensionError, PluginRuntime +from app.extensions.runtime import DeclarativePluginHost + +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 == {"level": "success", "message": "ABCD"} + + +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): + self.context = context + return PluginCommandEffect(type="none") + + 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_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_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") + assert container.credentials.resolve("plugin.text-tools.api_key") == plaintext + + deleted = container.plugins.delete_setting_secret("text-tools", "api_key") + assert deleted.configured is False + assert container.credentials.resolve("plugin.text-tools.api_key") 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") + + container.plugins.uninstall("text-tools") + + settings_path = get_settings().data_dir / "plugins" / "settings.json" + stored = json.loads(settings_path.read_text(encoding="utf-8")) + assert "text-tools" not in stored + assert container.credentials.resolve("plugin.text-tools.api_key") is None + + +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" + + +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" + assert container.credentials.resolve("plugin.text-tools.api_key") is None diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 8891885..b7a5e0a 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -271,6 +271,66 @@ 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 interface PluginCommandEffect { + type: 'none' | 'notification' | 'navigate' | 'refresh' | 'job' + payload: Record +} + +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..612779a --- /dev/null +++ b/frontend/src/services/pluginService.spec.ts @@ -0,0 +1,84 @@ +// @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: { 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') + 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}`) }