feat(extension): 实现插件命令与设置贡献
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
当前回归基线为后端 103 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||||
|
|
||||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ pnpm test
|
|||||||
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
|
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
|
||||||
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
| [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 映射、状态与错误边界 |
|
| [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 与合并流程 |
|
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
||||||
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
|
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
|
||||||
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
|
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
|
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
|
```powershell
|
||||||
uv sync
|
uv sync
|
||||||
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||||
|
|
||||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def build_container() -> ApplicationContainer:
|
|||||||
|
|
||||||
plugins = PluginRuntime(
|
plugins = PluginRuntime(
|
||||||
tools,
|
tools,
|
||||||
|
credentials=credentials,
|
||||||
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
|
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
|
||||||
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
|
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
|
||||||
allow_unsandboxed_mcp=settings.environment == "development",
|
allow_unsandboxed_mcp=settings.environment == "development",
|
||||||
|
|||||||
@@ -486,6 +486,98 @@ class PluginHostStatus(Contract):
|
|||||||
error: str | None = None
|
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):
|
class PluginPermissionGrantRequest(Contract):
|
||||||
permissions: list[str] = Field(default_factory=list)
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
from app.extensions.runtime import (
|
from app.extensions.errors import ExtensionError
|
||||||
AgentConfiguration,
|
from app.extensions.runtime import AgentConfiguration, PluginRuntime, SkillRuntime
|
||||||
ExtensionError,
|
|
||||||
PluginRuntime,
|
|
||||||
SkillRuntime,
|
|
||||||
)
|
|
||||||
from app.extensions.mcp import McpBridge, McpBridgeError
|
from app.extensions.mcp import McpBridge, McpBridgeError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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 {}
|
||||||
@@ -16,8 +16,15 @@ from app.agent.permissions import KNOWN_PERMISSIONS
|
|||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
ModelCapability,
|
ModelCapability,
|
||||||
Plugin,
|
Plugin,
|
||||||
|
PluginCommand,
|
||||||
|
PluginCommandContext,
|
||||||
|
PluginCommandEffect,
|
||||||
|
PluginCommandLocation,
|
||||||
|
PluginCommandResult,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginHostStatus,
|
PluginHostStatus,
|
||||||
|
PluginSecretStatus,
|
||||||
|
PluginSettingsSchema,
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
RetrievalConfig,
|
RetrievalConfig,
|
||||||
Skill,
|
Skill,
|
||||||
@@ -25,27 +32,21 @@ from app.contracts import (
|
|||||||
SkillStatus,
|
SkillStatus,
|
||||||
ToolDefinition,
|
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.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
|
||||||
|
from app.providers.credentials import EncryptedCredentialStore
|
||||||
|
|
||||||
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
_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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AgentConfiguration:
|
class AgentConfiguration:
|
||||||
skill_id: str
|
skill_id: str
|
||||||
@@ -238,13 +239,42 @@ class DeclarativePluginHost:
|
|||||||
return {"text": str(values.get("text", "")).upper()}
|
return {"text": str(values.get("text", "")).upper()}
|
||||||
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
|
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)
|
@dataclass(slots=True)
|
||||||
class _PluginRecord:
|
class _PluginRecord:
|
||||||
plugin: Plugin
|
plugin: Plugin
|
||||||
tools: list[DeclarativeToolSpec]
|
tools: list[DeclarativeToolSpec]
|
||||||
|
commands: list[PluginCommandSpec]
|
||||||
|
settings_definition: PluginSettingsDefinition | None
|
||||||
package_path: Path
|
package_path: Path
|
||||||
registered_tools: list[str]
|
registered_tools: list[str]
|
||||||
|
registered_commands: list[str]
|
||||||
mcp_remote_names: dict[str, str]
|
mcp_remote_names: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
@@ -256,12 +286,15 @@ class PluginRuntime:
|
|||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
host: DeclarativePluginHost | None = None,
|
host: DeclarativePluginHost | None = None,
|
||||||
mcp_bridge: McpBridge | None = None,
|
mcp_bridge: McpBridge | None = None,
|
||||||
|
credentials: EncryptedCredentialStore | None = None,
|
||||||
*,
|
*,
|
||||||
allow_unsandboxed_mcp: bool = False,
|
allow_unsandboxed_mcp: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.registry = tools
|
self.registry = tools
|
||||||
self.host = host or DeclarativePluginHost()
|
self.host = host or DeclarativePluginHost()
|
||||||
self.mcp = mcp_bridge or McpBridge()
|
self.mcp = mcp_bridge or McpBridge()
|
||||||
|
self.commands = CommandRegistry()
|
||||||
|
self.settings = PluginSettingsStore(credentials or EncryptedCredentialStore())
|
||||||
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
|
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
|
||||||
self._records: dict[str, _PluginRecord] = {}
|
self._records: dict[str, _PluginRecord] = {}
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
@@ -287,6 +320,8 @@ class PluginRuntime:
|
|||||||
|
|
||||||
_validate_backend(manifest)
|
_validate_backend(manifest)
|
||||||
specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
|
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":
|
if manifest.backend.type != "mcp":
|
||||||
declared = set(manifest.contributes.tools)
|
declared = set(manifest.contributes.tools)
|
||||||
actual = {spec.name for spec in specs}
|
actual = {spec.name for spec in specs}
|
||||||
@@ -305,6 +340,47 @@ class PluginRuntime:
|
|||||||
f"Tool permission is not declared by Plugin: {spec.permission}",
|
f"Tool permission is not declared by Plugin: {spec.permission}",
|
||||||
details={"tool": spec.name, "permission": 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(
|
record = _PluginRecord(
|
||||||
plugin=Plugin(
|
plugin=Plugin(
|
||||||
@@ -316,8 +392,11 @@ class PluginRuntime:
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
tools=specs,
|
tools=specs,
|
||||||
|
commands=command_specs,
|
||||||
|
settings_definition=settings_definition,
|
||||||
package_path=root,
|
package_path=root,
|
||||||
registered_tools=[],
|
registered_tools=[],
|
||||||
|
registered_commands=[],
|
||||||
mcp_remote_names={},
|
mcp_remote_names={},
|
||||||
)
|
)
|
||||||
self._records[manifest.plugin_id] = record
|
self._records[manifest.plugin_id] = record
|
||||||
@@ -368,6 +447,16 @@ class PluginRuntime:
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
details={"plugin_id": plugin_id, "tools": conflicts},
|
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
|
record.plugin.status = PluginStatus.starting
|
||||||
try:
|
try:
|
||||||
if record.plugin.manifest.backend.type == "mcp":
|
if record.plugin.manifest.backend.type == "mcp":
|
||||||
@@ -405,11 +494,36 @@ class PluginRuntime:
|
|||||||
executor,
|
executor,
|
||||||
)
|
)
|
||||||
record.registered_tools.append(spec.name)
|
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:
|
except Exception as exc:
|
||||||
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
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_remote_names.clear()
|
||||||
self.mcp.stop(plugin_id)
|
self.mcp.stop(plugin_id)
|
||||||
record.plugin.status = PluginStatus.error
|
record.plugin.status = PluginStatus.error
|
||||||
@@ -468,6 +582,9 @@ class PluginRuntime:
|
|||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
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_remote_names.clear()
|
||||||
if record.plugin.manifest.backend.type == "mcp":
|
if record.plugin.manifest.backend.type == "mcp":
|
||||||
self.mcp.stop(plugin_id)
|
self.mcp.stop(plugin_id)
|
||||||
@@ -479,6 +596,43 @@ class PluginRuntime:
|
|||||||
record = self._record(plugin_id)
|
record = self._record(plugin_id)
|
||||||
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
|
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:
|
def restart_host(self, plugin_id: str) -> PluginHostStatus:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._restart_host(plugin_id)
|
return self._restart_host(plugin_id)
|
||||||
@@ -506,6 +660,9 @@ class PluginRuntime:
|
|||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
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_remote_names.clear()
|
||||||
self.mcp.stop(plugin_id)
|
self.mcp.stop(plugin_id)
|
||||||
record.plugin.enabled = False
|
record.plugin.enabled = False
|
||||||
@@ -567,6 +724,9 @@ class PluginRuntime:
|
|||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
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_remote_names.clear()
|
||||||
record.plugin.enabled = False
|
record.plugin.enabled = False
|
||||||
record.plugin.status = PluginStatus.error
|
record.plugin.status = PluginStatus.error
|
||||||
@@ -594,6 +754,7 @@ class PluginRuntime:
|
|||||||
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
|
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
|
||||||
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
|
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
|
||||||
self.mcp.remove(plugin_id)
|
self.mcp.remove(plugin_id)
|
||||||
|
self.settings.remove_plugin(plugin_id)
|
||||||
del self._records[plugin_id]
|
del self._records[plugin_id]
|
||||||
|
|
||||||
def _record(self, plugin_id: str) -> _PluginRecord:
|
def _record(self, plugin_id: str) -> _PluginRecord:
|
||||||
@@ -615,6 +776,46 @@ class PluginRuntime:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _manifest_error("plugin tool", exc) from 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:
|
def _package_dir(package_path: str | Path) -> Path:
|
||||||
root = Path(package_path).expanduser().resolve()
|
root = Path(package_path).expanduser().resolve()
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ from app.contracts import (
|
|||||||
PageMeta,
|
PageMeta,
|
||||||
PermissionDecisionRequest,
|
PermissionDecisionRequest,
|
||||||
Plugin,
|
Plugin,
|
||||||
|
PluginCommandExecuteRequest,
|
||||||
|
PluginCommandListResponse,
|
||||||
|
PluginCommandLocation,
|
||||||
|
PluginCommandResult,
|
||||||
PluginHostStatus,
|
PluginHostStatus,
|
||||||
PluginListResponse,
|
PluginListResponse,
|
||||||
PluginPermissionGrantRequest,
|
PluginPermissionGrantRequest,
|
||||||
|
PluginSecretStatus,
|
||||||
|
PluginSecretWriteRequest,
|
||||||
|
PluginSettingsSchema,
|
||||||
|
PluginSettingsUpdateRequest,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ProviderCreateRequest,
|
ProviderCreateRequest,
|
||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
@@ -559,6 +567,86 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
|||||||
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
|
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
|
# Providers
|
||||||
@router.get(
|
@router.get(
|
||||||
"/credentials/{credential_id}",
|
"/credentials/{credential_id}",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,10 @@ permissions: []
|
|||||||
contributes:
|
contributes:
|
||||||
tools:
|
tools:
|
||||||
- text.uppercase
|
- text.uppercase
|
||||||
|
commands:
|
||||||
|
- text-tools.uppercase-selection
|
||||||
|
settings_sections:
|
||||||
|
- text-tools.general
|
||||||
backend:
|
backend:
|
||||||
type: internal_rpc
|
type: internal_rpc
|
||||||
transport: none
|
transport: none
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -95,6 +95,10 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
|
|||||||
"/api/plugins/install",
|
"/api/plugins/install",
|
||||||
"/api/plugins/{plugin_id}/host",
|
"/api/plugins/{plugin_id}/host",
|
||||||
"/api/plugins/{plugin_id}/host/restart",
|
"/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}/enable",
|
||||||
"/api/plugins/{plugin_id}/disable",
|
"/api/plugins/{plugin_id}/disable",
|
||||||
"/api/providers/test",
|
"/api/providers/test",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -271,6 +271,66 @@ export interface PluginHostStatus {
|
|||||||
error?: string | null
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
secrets: Record<string, { configured: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSecretStatus {
|
||||||
|
plugin_id: string
|
||||||
|
key: string
|
||||||
|
configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContribution {
|
export interface PluginContribution {
|
||||||
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
|
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import apiClient from './apiClient'
|
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 {
|
function toPlugin(plugin: ApiPlugin): Plugin {
|
||||||
const { manifest } = plugin
|
const { manifest } = plugin
|
||||||
@@ -62,6 +74,55 @@ export async function restartPluginHost(pluginId: string): Promise<OperationResp
|
|||||||
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
|
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPluginCommands(location?: PluginCommandLocation): Promise<PluginCommand[]> {
|
||||||
|
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<string, unknown> = {},
|
||||||
|
context: PluginCommandContext = {},
|
||||||
|
): Promise<PluginCommandResult> {
|
||||||
|
return apiClient.post(`/api/plugin-contributions/commands/${encodeURIComponent(commandId)}/execute`, {
|
||||||
|
arguments: argumentsValue,
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginSettings(pluginId: string): Promise<PluginSettingsSchema> {
|
||||||
|
return apiClient.get(`/api/plugins/${encodeURIComponent(pluginId)}/settings`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginSettings(
|
||||||
|
pluginId: string,
|
||||||
|
schemaVersion: number,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): Promise<PluginSettingsSchema> {
|
||||||
|
return apiClient.put(`/api/plugins/${encodeURIComponent(pluginId)}/settings`, {
|
||||||
|
schema_version: schemaVersion,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putPluginSecret(
|
||||||
|
pluginId: string,
|
||||||
|
key: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<PluginSecretStatus> {
|
||||||
|
return apiClient.put(
|
||||||
|
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
|
||||||
|
{ secret },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePluginSecret(pluginId: string, key: string): Promise<PluginSecretStatus> {
|
||||||
|
return apiClient.delete(
|
||||||
|
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
|
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
|
||||||
return apiClient.delete(`/api/plugins/${pluginId}`)
|
return apiClient.delete(`/api/plugins/${pluginId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user