feat(extension): 实现插件命令与设置贡献
This commit is contained in:
+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
|
||||
Reference in New Issue
Block a user