feat(extension): 实现插件命令与设置贡献
This commit is contained in:
@@ -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 恢复、事件契约与脱敏问题复盘 |
|
||||
|
||||
+2
-2
@@ -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_<ID>` 注入;不要把真实密钥写入仓库。
|
||||
当前基线为 103 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||
|
||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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 (
|
||||
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()
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
tools:
|
||||
- text.uppercase
|
||||
commands:
|
||||
- text-tools.uppercase-selection
|
||||
settings_sections:
|
||||
- text-tools.general
|
||||
backend:
|
||||
type: internal_rpc
|
||||
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/{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",
|
||||
|
||||
@@ -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
|
||||
@@ -32,6 +32,7 @@
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
||||
- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md)
|
||||
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
|
||||
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
|
||||
- [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
||||
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
||||
|
||||
> 实施状态更新:2026-09-01。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已完成。后续继续接入真实音频处理、Plugin Command/Settings、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||
> 实施状态更新:2026-09-02。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成。后续继续接入真实音频处理、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||
|
||||
---
|
||||
|
||||
@@ -2329,7 +2329,7 @@ Markdown Workspace
|
||||
|
||||
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
|
||||
|
||||
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 也已完成。当前验证基线为后端 103 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
|
||||
|
||||
第二阶段在既有 Contract 上接入:
|
||||
|
||||
|
||||
@@ -748,8 +748,8 @@ Markdown
|
||||
- [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript;
|
||||
- [x] MCP Server 能通过 MCP Bridge 注册 Tool;
|
||||
- [x] Agent 能调用 MCP Tool;
|
||||
- [ ] Plugin Command Contribution 后端可注册;
|
||||
- [ ] Plugin Settings Contribution 后端可解析;
|
||||
- [x] Plugin Command Contribution 后端可注册;
|
||||
- [x] Plugin Settings Contribution 后端可解析;
|
||||
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
|
||||
- [ ] 完成跨模块接口审阅和第二阶段集成。
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ RunCancelled
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
更新至 2026-09-01:后端 92 项回归测试通过。
|
||||
更新至 2026-09-02:后端 103 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节。
|
||||
|
||||
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
||||
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
||||
|
||||
@@ -47,11 +47,11 @@
|
||||
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
||||
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
||||
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
|
||||
| Plugin Command | GET | `/api/plugin-contributions/commands` | 计划新增 | 获取前端可展示的 Command |
|
||||
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 计划新增 | 受控执行 Command |
|
||||
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 计划新增 | 获取 Schema 与非敏感配置 |
|
||||
| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 计划新增 | 更新非敏感配置 |
|
||||
| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 计划新增 | 写入或删除 Secret Reference |
|
||||
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
|
||||
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
||||
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
||||
| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 已实现 | 更新非敏感配置 |
|
||||
| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 已实现 | 写入或删除 Secret Reference |
|
||||
| Provider | 现有路径 | `/api/providers/*`、`POST /api/chat` | 扩展 | 补齐协议能力和统一行为 |
|
||||
| Retrieval | GET/POST | `/api/index/status`、`/api/index/rebuild` | 扩展 | 暴露 Embedding 兼容状态并安全重建向量 |
|
||||
| Benchmark | GET | `/api/benchmarks/datasets` | 计划新增 | 枚举受控 Dataset |
|
||||
@@ -542,6 +542,8 @@ error
|
||||
|
||||
允许的 effect 首批为 `none`、`notification`、`navigate`、`refresh` 和 `job`。前端仅执行白名单 effect;未知类型显示结果但不执行。
|
||||
|
||||
当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。
|
||||
|
||||
### 7.5 Settings Schema
|
||||
|
||||
`GET /api/plugins/{plugin_id}/settings`
|
||||
@@ -623,6 +625,8 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret
|
||||
|
||||
Secret 明文不进入普通 Settings、日志、Trace、Benchmark Dataset 或前端持久化。
|
||||
|
||||
非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的 Secret Reference;Secret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。
|
||||
|
||||
### 7.7 Plugin/MCP 错误码
|
||||
|
||||
```text
|
||||
@@ -636,10 +640,21 @@ MCP_TOOL_CALL_FAILED
|
||||
MCP_TOOL_RESULT_TOO_LARGE
|
||||
MCP_TRUST_APPROVAL_REQUIRED
|
||||
PLUGIN_COMMAND_NOT_FOUND
|
||||
PLUGIN_COMMAND_CONFLICT
|
||||
PLUGIN_COMMAND_INVALID
|
||||
PLUGIN_COMMAND_ARGUMENT_INVALID
|
||||
PLUGIN_COMMAND_CONTEXT_INVALID
|
||||
PLUGIN_COMMAND_TIMEOUT
|
||||
PLUGIN_COMMAND_EXECUTION_FAILED
|
||||
PLUGIN_COMMAND_RESULT_INVALID
|
||||
PLUGIN_COMMAND_RESULT_TOO_LARGE
|
||||
PLUGIN_SETTINGS_SCHEMA_INVALID
|
||||
PLUGIN_SETTINGS_VERSION_CONFLICT
|
||||
PLUGIN_SETTINGS_FIELD_INVALID
|
||||
PLUGIN_SECRET_FIELD_NOT_FOUND
|
||||
PLUGIN_SECRET_VALUE_INVALID
|
||||
PLUGIN_SECRET_STORE_ERROR
|
||||
PLUGIN_STORAGE_ERROR
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
|
||||
|
||||
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。
|
||||
> 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 103 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
@@ -68,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
|
||||
- Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供;
|
||||
- FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供;
|
||||
- 文件系统和 API Key 明文读取:由 Rust Host 提供;
|
||||
- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。
|
||||
- Frontend Extension Slot 与 Plugin Command/Settings UI:后端 Contract 与前端 Service 已完成,页面由前端后续联调。
|
||||
|
||||
## Provider
|
||||
|
||||
@@ -349,4 +349,4 @@ Skill Manifest
|
||||
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
||||
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。
|
||||
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
|
||||
- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP Server;Streamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。
|
||||
- 当前 Plugin Host 支持内置声明式 handler、本地 stdio MCP Server 以及 Plugin Command/Settings;Streamable HTTP、OS 级沙箱与 UI Contribution 留在后续阶段。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
|
||||
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
|
||||
|
||||
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。
|
||||
> 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 103 项测试通过。
|
||||
|
||||
## 当前实现
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MCP Bridge 与 Plugin Host 开发说明
|
||||
|
||||
> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。
|
||||
> 更新日期:2026-09-02。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。阶段 D 的 Plugin Command 与 Settings 已在其独立开发说明中落地。
|
||||
|
||||
## 1. 目标与实现状态
|
||||
|
||||
@@ -279,4 +279,4 @@ pnpm build
|
||||
- 一键安装前的完整命令展示与确认 UI;
|
||||
- Tool 列表热更新的无中断替换。
|
||||
|
||||
阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。
|
||||
阶段 D 已在当前 Plugin Runtime 上增加 Command、Settings、Secret Contract 和命名空间 Storage,且未修改 Agent 使用内部 Tool Contract 的原则。实现细节见《Plugin Command 与 Settings 开发说明》。
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Plugin Command 与 Settings 开发说明
|
||||
|
||||
> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 103 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。
|
||||
|
||||
## 1. 阶段目标
|
||||
|
||||
阶段 D 在阶段 C 的 Plugin Runtime 与隔离 MCP Host 上补齐两类宿主贡献:
|
||||
|
||||
- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect;
|
||||
- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用;
|
||||
- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调。
|
||||
|
||||
本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。
|
||||
|
||||
## 2. 包内声明
|
||||
|
||||
Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_sections` 声明贡献标识,并分别提供 `commands.yaml`、`settings.yaml`。安装时宿主要求声明集合与文件内容完全一致,拒绝重复项、越过 Plugin 命名空间的 ID、未声明权限和无效 Schema。
|
||||
|
||||
`commands.yaml` 的首批字段包括:
|
||||
|
||||
- `command_id`、标题、描述、宿主图标;
|
||||
- `locations`:`command_palette`、`context_menu` 或 `toolbar`;
|
||||
- `when` 与允许传入执行器的 Context 字段;
|
||||
- 参数 JSON Schema、可选权限、受控 handler 和超时。
|
||||
|
||||
`settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string`、`number`、`boolean`、`select`、`secret`。宿主会校验默认值、必填项、数值边界、Select 选项,以及 Secret 不得携带默认明文。
|
||||
|
||||
仓库内 `text-tools` 是联调 Fixture,覆盖 Command 和五种 Settings 字段类型。
|
||||
|
||||
## 3. Command 运行链路
|
||||
|
||||
`CommandRegistry` 只发布处于启用状态的 Plugin Command。Plugin 禁用、Host 不可用或重启时,Command 与 Tool 使用同样的注销/重新注册生命周期,避免前端看到实际不可执行的命令。
|
||||
|
||||
执行顺序如下:
|
||||
|
||||
1. 查找已注册 Command;
|
||||
2. 使用 Draft 2020-12 JSON Schema 校验 arguments;
|
||||
3. 根据 `when` 检查必要上下文;
|
||||
4. 仅向执行器传递声明过的 Context 字段;
|
||||
5. 在超时范围内调用宿主受控 handler;
|
||||
6. 校验 effect 类型、可序列化性和 64 KiB 大小上限;
|
||||
7. 返回统一 `PluginCommandResult`。
|
||||
|
||||
首批 effect 为 `none`、`notification`、`navigate`、`refresh` 和 `job`。前端不得把 effect 当作任意代码执行。
|
||||
|
||||
Command 审计使用 500 条有界内存队列,仅保留 `command_id`、`plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。
|
||||
|
||||
当前声明式宿主提供安全白名单 handler,后续如允许 MCP Server 承担 Command 逻辑,应增加独立的 MCP Command Target Contract,不能把插件给出的模块路径或 Shell 字符串直接执行。
|
||||
|
||||
## 4. Settings 与 Secret 边界
|
||||
|
||||
普通 Settings 以 Plugin 为命名空间持久化到:
|
||||
|
||||
```text
|
||||
APP_DATA_DIR/plugins/settings.json
|
||||
```
|
||||
|
||||
该文件只包含:
|
||||
|
||||
- 当前 Schema 版本;
|
||||
- 非敏感字段值;
|
||||
- Secret 的确定性引用,例如 `plugin.text-tools.api_key`。
|
||||
|
||||
Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。
|
||||
|
||||
开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 Keychain,HTTP Contract 无需因此改变。
|
||||
|
||||
## 5. HTTP 与前端 Service
|
||||
|
||||
后端已实现:
|
||||
|
||||
```text
|
||||
GET /api/plugin-contributions/commands?location=command_palette
|
||||
POST /api/plugin-contributions/commands/{command_id}/execute
|
||||
GET /api/plugins/{plugin_id}/settings
|
||||
PUT /api/plugins/{plugin_id}/settings
|
||||
PUT /api/plugins/{plugin_id}/settings/{key}/secret
|
||||
DELETE /api/plugins/{plugin_id}/settings/{key}/secret
|
||||
```
|
||||
|
||||
前端 `pluginService` 已提供对应方法及 Wire DTO,但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志。
|
||||
|
||||
## 6. 主要错误边界
|
||||
|
||||
- Command 未注册、冲突、参数或 Context 无效;
|
||||
- 执行超时、执行器异常、effect 无效或过大;
|
||||
- Settings Schema 无效、版本冲突、字段类型/边界错误;
|
||||
- Secret 字段不存在、空 Secret、凭据存储异常;
|
||||
- Settings JSON 根结构或 Plugin 命名空间损坏。
|
||||
|
||||
以上错误统一转换为 `ExtensionError` 和稳定业务错误码,HTTP 层不暴露内部堆栈、Secret 或插件返回的原始异常。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run pytest
|
||||
|
||||
cd ../frontend
|
||||
pnpm test -- --run
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
|
||||
|
||||
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
|
||||
@@ -186,13 +186,13 @@ pnpm build
|
||||
|
||||
```text
|
||||
pnpm build passed
|
||||
pnpm test 27 passed
|
||||
uv run pytest 92 passed
|
||||
pnpm test 29 passed
|
||||
uv run pytest 103 passed
|
||||
preview smoke HTTP 200
|
||||
git diff --check passed
|
||||
```
|
||||
|
||||
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。
|
||||
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 103 项测试结果,也不涉及产品代码。
|
||||
|
||||
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
|
||||
|
||||
|
||||
@@ -104,4 +104,4 @@ pnpm build
|
||||
|
||||
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
|
||||
|
||||
当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
当前完整回归基线:后端 103 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
|
||||
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
|
||||
|
||||
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
|
||||
> 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化、stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 103 项测试通过。
|
||||
|
||||
## 1. 审阅结论
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
|
||||
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 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<OperationResp
|
||||
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> {
|
||||
return apiClient.delete(`/api/plugins/${pluginId}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user