Merge remote-tracking branch 'origin/main' into feat/knowledge-retrieval-core
# Conflicts: # README.md # backend/app/routes.py # docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md # docs/development/Knowledge与Retrieval-Core开发说明.md
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>` 注入;不要把真实密钥写入仓库。
|
||||
当前基线为 136 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
|
||||
|
||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||
|
||||
from app.contracts import ToolCall, ToolDefinition, ToolResult
|
||||
from app.schema_security import reject_external_schema_references
|
||||
|
||||
ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]]
|
||||
|
||||
@@ -54,6 +55,8 @@ class ToolRegistry:
|
||||
arguments_model: type[BaseModel],
|
||||
executor: ToolExecutor,
|
||||
) -> None:
|
||||
Draft202012Validator.check_schema(definition.parameters)
|
||||
reject_external_schema_references(definition.parameters)
|
||||
with self._lock:
|
||||
if definition.name in self._tools:
|
||||
raise ValueError(f"Tool already registered: {definition.name}")
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.agent.builtin_tools import register_builtin_tools
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.config import BACKEND_DIR, get_settings
|
||||
from app.extensions import PluginRuntime, SkillRuntime
|
||||
from app.extensions.mcp_registry import McpServerRegistry
|
||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
@@ -22,6 +23,7 @@ class ApplicationContainer:
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
plugins: PluginRuntime
|
||||
mcp_servers: McpServerRegistry
|
||||
agent: AgentRuntime
|
||||
|
||||
|
||||
@@ -53,6 +55,7 @@ def build_container() -> ApplicationContainer:
|
||||
|
||||
plugins = PluginRuntime(
|
||||
tools,
|
||||
credentials=credentials,
|
||||
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
|
||||
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
|
||||
allow_unsandboxed_mcp=settings.environment == "development",
|
||||
@@ -60,6 +63,14 @@ def build_container() -> ApplicationContainer:
|
||||
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
|
||||
plugins.enable("text-tools")
|
||||
|
||||
mcp_servers = McpServerRegistry(
|
||||
tools,
|
||||
credentials,
|
||||
settings.data_dir,
|
||||
allow_process_launch=settings.environment == "development",
|
||||
)
|
||||
mcp_servers.restore_enabled()
|
||||
|
||||
skills = SkillRuntime(tools)
|
||||
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
|
||||
skills.enable("knowledge-assistant")
|
||||
@@ -80,6 +91,7 @@ def build_container() -> ApplicationContainer:
|
||||
permissions=permissions,
|
||||
skills=skills,
|
||||
plugins=plugins,
|
||||
mcp_servers=mcp_servers,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
|
||||
+259
-2
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
||||
|
||||
@@ -205,7 +205,7 @@ class ToolDefinition(Contract):
|
||||
description: str
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
permission: str | None = None
|
||||
source: Literal["builtin", "plugin"] = "builtin"
|
||||
source: Literal["builtin", "plugin", "mcp_server"] = "builtin"
|
||||
|
||||
|
||||
class ToolCall(Contract):
|
||||
@@ -492,6 +492,263 @@ class PluginHostStatus(Contract):
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Independent user-managed MCP Server Registry. This is deliberately separate
|
||||
# from Plugin manifests: a server can contribute tools without being a Plugin.
|
||||
class McpServerTransport(str, Enum):
|
||||
stdio = "stdio"
|
||||
streamable_http = "streamable_http"
|
||||
sse = "sse"
|
||||
|
||||
|
||||
class McpServerConfig(Contract):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
transport: McpServerTransport = McpServerTransport.stdio
|
||||
command: str | None = Field(default=None, max_length=1024)
|
||||
args: list[str] = Field(default_factory=list, max_length=64)
|
||||
url: str | None = Field(default=None, max_length=4096)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
environment: dict[str, str] = Field(default_factory=dict)
|
||||
secret_environment_keys: list[str] = Field(default_factory=list)
|
||||
secret_header_keys: list[str] = Field(default_factory=list)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
|
||||
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
|
||||
|
||||
|
||||
class McpServerCreateRequest(McpServerConfig):
|
||||
pass
|
||||
|
||||
|
||||
class McpServerUpdateRequest(McpServerConfig):
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class McpServerSecretWriteRequest(Contract):
|
||||
secret: SecretStr = Field(min_length=1, max_length=32768)
|
||||
|
||||
|
||||
class McpServerSecretStatus(Contract):
|
||||
key: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class McpServerTrustRequest(Contract):
|
||||
command_digest: str = Field(min_length=64, max_length=64)
|
||||
|
||||
|
||||
class McpServerStatus(Contract):
|
||||
enabled: bool = False
|
||||
status: PluginHostState = PluginHostState.stopped
|
||||
tools_count: int = 0
|
||||
protocol_version: str | None = None
|
||||
remote_server_name: str | None = None
|
||||
remote_server_version: str | None = None
|
||||
error: str | None = None
|
||||
last_tested_at: datetime | None = None
|
||||
last_test_succeeded: bool | None = None
|
||||
|
||||
|
||||
class McpServer(McpServerStatus):
|
||||
server_id: str
|
||||
version: int
|
||||
name: str
|
||||
transport: McpServerTransport
|
||||
command: str | None = None
|
||||
args: list[str] = Field(default_factory=list)
|
||||
url: str | None = None
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
environment: dict[str, str] = Field(default_factory=dict)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
startup_timeout_seconds: float
|
||||
tool_timeout_seconds: float
|
||||
secret_environment: dict[str, bool] = Field(default_factory=dict)
|
||||
secret_headers: dict[str, bool] = Field(default_factory=dict)
|
||||
trusted: bool = False
|
||||
command_digest: str
|
||||
command_summary: str
|
||||
|
||||
|
||||
class McpServerListResponse(Contract):
|
||||
items: list[McpServer] = Field(default_factory=list)
|
||||
|
||||
|
||||
class McpToolSummary(Contract):
|
||||
name: str
|
||||
remote_name: str
|
||||
description: str
|
||||
permission: str | None = None
|
||||
|
||||
|
||||
class McpToolSummaryListResponse(Contract):
|
||||
items: list[McpToolSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginCommandLocation(str, Enum):
|
||||
command_palette = "command_palette"
|
||||
context_menu = "context_menu"
|
||||
toolbar = "toolbar"
|
||||
|
||||
|
||||
class PluginCommand(Contract):
|
||||
command_id: str
|
||||
plugin_id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
icon: str | None = None
|
||||
locations: list[PluginCommandLocation] = Field(default_factory=list)
|
||||
when: list[str] = Field(default_factory=list)
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PluginCommandListResponse(Contract):
|
||||
items: list[PluginCommand] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginCommandContext(Contract):
|
||||
vault_id: str | None = None
|
||||
note_id: str | None = None
|
||||
file_path: str | None = None
|
||||
selection: str | None = None
|
||||
|
||||
|
||||
class PluginCommandExecuteRequest(Contract):
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
context: PluginCommandContext = Field(default_factory=PluginCommandContext)
|
||||
|
||||
|
||||
class PluginNotificationEffectPayload(Contract):
|
||||
level: Literal["info", "success", "warning", "error"] = "info"
|
||||
message: str = Field(min_length=1, max_length=4096)
|
||||
|
||||
|
||||
class PluginNavigateEffectPayload(Contract):
|
||||
route: Literal[
|
||||
"vault-entry",
|
||||
"workspace",
|
||||
"search",
|
||||
"chat",
|
||||
"agent",
|
||||
"tasks",
|
||||
"skills",
|
||||
"plugins",
|
||||
"themes",
|
||||
"settings",
|
||||
]
|
||||
|
||||
|
||||
class PluginRefreshEffectPayload(Contract):
|
||||
scope: Literal["workspace", "commands", "settings", "plugins"]
|
||||
|
||||
|
||||
class PluginJobEffectPayload(Contract):
|
||||
job_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$",
|
||||
)
|
||||
|
||||
|
||||
class PluginNoEffectPayload(Contract):
|
||||
pass
|
||||
|
||||
|
||||
class PluginNoEffect(Contract):
|
||||
type: Literal["none"] = "none"
|
||||
payload: PluginNoEffectPayload = Field(default_factory=PluginNoEffectPayload)
|
||||
|
||||
|
||||
class PluginNotificationEffect(Contract):
|
||||
type: Literal["notification"] = "notification"
|
||||
payload: PluginNotificationEffectPayload
|
||||
|
||||
|
||||
class PluginNavigateEffect(Contract):
|
||||
type: Literal["navigate"] = "navigate"
|
||||
payload: PluginNavigateEffectPayload
|
||||
|
||||
|
||||
class PluginRefreshEffect(Contract):
|
||||
type: Literal["refresh"] = "refresh"
|
||||
payload: PluginRefreshEffectPayload
|
||||
|
||||
|
||||
class PluginJobEffect(Contract):
|
||||
type: Literal["job"] = "job"
|
||||
payload: PluginJobEffectPayload
|
||||
|
||||
|
||||
PluginCommandEffect = Annotated[
|
||||
PluginNoEffect
|
||||
| PluginNotificationEffect
|
||||
| PluginNavigateEffect
|
||||
| PluginRefreshEffect
|
||||
| PluginJobEffect,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
PLUGIN_COMMAND_EFFECT_TYPES = (
|
||||
PluginNoEffect,
|
||||
PluginNotificationEffect,
|
||||
PluginNavigateEffect,
|
||||
PluginRefreshEffect,
|
||||
PluginJobEffect,
|
||||
)
|
||||
|
||||
|
||||
class PluginCommandResult(Contract):
|
||||
command_id: str
|
||||
status: Literal["completed"] = "completed"
|
||||
effect: PluginCommandEffect = Field(default_factory=PluginNoEffect)
|
||||
|
||||
|
||||
class PluginSettingType(str, Enum):
|
||||
string = "string"
|
||||
number = "number"
|
||||
boolean = "boolean"
|
||||
select = "select"
|
||||
secret = "secret"
|
||||
|
||||
|
||||
class PluginSettingField(Contract):
|
||||
key: str
|
||||
label: str
|
||||
description: str = ""
|
||||
type: PluginSettingType
|
||||
required: bool = False
|
||||
default: Any | None = None
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
options: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginSecretState(Contract):
|
||||
configured: bool = False
|
||||
|
||||
|
||||
class PluginSettingsSchema(Contract):
|
||||
plugin_id: str
|
||||
schema_version: int = Field(ge=1)
|
||||
fields: list[PluginSettingField] = Field(default_factory=list)
|
||||
values: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, PluginSecretState] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PluginSettingsUpdateRequest(Contract):
|
||||
schema_version: int = Field(ge=1)
|
||||
values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PluginSecretWriteRequest(Contract):
|
||||
secret: SecretStr
|
||||
|
||||
|
||||
class PluginSecretStatus(Contract):
|
||||
plugin_id: str
|
||||
key: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class PluginPermissionGrantRequest(Contract):
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -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,847 @@
|
||||
"""Plugin Command Registry 与 Settings/Secret 命名空间存储。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Any, Awaitable, Callable, Literal
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
PLUGIN_COMMAND_EFFECT_TYPES,
|
||||
PluginCommand,
|
||||
PluginCommandContext,
|
||||
PluginCommandEffect,
|
||||
PluginCommandLocation,
|
||||
PluginCommandResult,
|
||||
PluginSecretState,
|
||||
PluginSecretStatus,
|
||||
PluginSettingField,
|
||||
PluginSettingType,
|
||||
PluginSettingsSchema,
|
||||
)
|
||||
from app.extensions.errors import ExtensionError
|
||||
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
||||
from app.schema_security import (
|
||||
SchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
|
||||
_HOST_ICONS = {"bolt", "document", "edit", "link", "refresh", "search", "setting"}
|
||||
_WHEN_TOKENS = {
|
||||
"workspace.has_vault",
|
||||
"editor.has_note",
|
||||
"editor.has_selection",
|
||||
}
|
||||
_CONTEXT_KEYS = {"vault_id", "note_id", "file_path", "selection"}
|
||||
_WHEN_CONTEXT = {
|
||||
"workspace.has_vault": "vault_id",
|
||||
"editor.has_note": "note_id",
|
||||
"editor.has_selection": "selection",
|
||||
}
|
||||
|
||||
|
||||
class PluginCommandSpec(BaseModel):
|
||||
"""包内 commands.yaml 的宿主侧声明,不直接暴露 handler。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
command_id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
icon: str | None = None
|
||||
locations: list[PluginCommandLocation] = Field(default_factory=list)
|
||||
when: list[str] = Field(default_factory=list)
|
||||
context: list[Literal["vault_id", "note_id", "file_path", "selection"]] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
parameters: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
)
|
||||
permission: str | None = None
|
||||
secrets: list[str] = Field(default_factory=list)
|
||||
handler: Literal["echo", "uppercase_selection"] | None = None
|
||||
mcp_tool: str | None = None
|
||||
timeout_seconds: int = Field(default=30, ge=1, le=120)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_execution_target(self) -> "PluginCommandSpec":
|
||||
if (self.handler is None) == (self.mcp_tool is None):
|
||||
raise ValueError("Command must declare exactly one handler or mcp_tool target.")
|
||||
return self
|
||||
|
||||
|
||||
CommandExecutor = Callable[
|
||||
[dict[str, Any], dict[str, Any]],
|
||||
PluginCommandEffect | Awaitable[PluginCommandEffect],
|
||||
]
|
||||
PluginSecretResolver = Callable[[str], str | None]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RegisteredCommand:
|
||||
command: PluginCommand
|
||||
spec: PluginCommandSpec
|
||||
executor: CommandExecutor
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginCommandAuditEvent:
|
||||
"""不记录参数与上下文的轻量审计事件,避免把正文或 Secret 写入日志。"""
|
||||
|
||||
command_id: str
|
||||
plugin_id: str
|
||||
status: Literal["completed", "failed"]
|
||||
duration_ms: int
|
||||
error_code: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
"""只发布已启用 Plugin 的受控 Command Contribution。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._commands: dict[str, _RegisteredCommand] = {}
|
||||
self._audit: deque[PluginCommandAuditEvent] = deque(maxlen=500)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def register(
|
||||
self,
|
||||
plugin_id: str,
|
||||
spec: PluginCommandSpec,
|
||||
executor: CommandExecutor,
|
||||
) -> None:
|
||||
validate_command_spec(plugin_id, spec)
|
||||
command = PluginCommand(
|
||||
command_id=spec.command_id,
|
||||
plugin_id=plugin_id,
|
||||
title=spec.title,
|
||||
description=spec.description,
|
||||
icon=spec.icon,
|
||||
locations=spec.locations,
|
||||
when=spec.when,
|
||||
parameters=spec.parameters,
|
||||
enabled=True,
|
||||
)
|
||||
with self._lock:
|
||||
if spec.command_id in self._commands:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_CONFLICT",
|
||||
f"Plugin command is already registered: {spec.command_id}",
|
||||
status_code=409,
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
self._commands[spec.command_id] = _RegisteredCommand(command, spec, executor)
|
||||
|
||||
def unregister(self, command_id: str) -> None:
|
||||
with self._lock:
|
||||
self._commands.pop(command_id, None)
|
||||
|
||||
def contains(self, command_id: str) -> bool:
|
||||
with self._lock:
|
||||
return command_id in self._commands
|
||||
|
||||
def list(self, location: PluginCommandLocation | None = None) -> list[PluginCommand]:
|
||||
with self._lock:
|
||||
items = [
|
||||
item.command.model_copy(deep=True)
|
||||
for item in self._commands.values()
|
||||
if location is None or location in item.command.locations
|
||||
]
|
||||
return sorted(items, key=lambda item: item.command_id)
|
||||
|
||||
def audit_events(self) -> list[PluginCommandAuditEvent]:
|
||||
"""返回有界审计快照;事件刻意不包含 arguments/context/effect。"""
|
||||
|
||||
with self._lock:
|
||||
return list(self._audit)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
command_id: str,
|
||||
arguments: dict[str, Any],
|
||||
context: PluginCommandContext,
|
||||
) -> PluginCommandResult:
|
||||
with self._lock:
|
||||
registered = self._commands.get(command_id)
|
||||
if registered is None:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_NOT_FOUND",
|
||||
f"Plugin command is not registered or enabled: {command_id}",
|
||||
status_code=404,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
started_at = perf_counter()
|
||||
try:
|
||||
Draft202012Validator(registered.spec.parameters).validate(arguments)
|
||||
except JsonSchemaValidationError as exc:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_ARGUMENT_INVALID",
|
||||
"Plugin command arguments do not match the declared schema.",
|
||||
details={"command_id": command_id, "path": list(exc.path)},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error from exc
|
||||
|
||||
raw_context = context.model_dump(exclude_none=True)
|
||||
missing = [
|
||||
token
|
||||
for token in registered.spec.when
|
||||
if not raw_context.get(_WHEN_CONTEXT[token])
|
||||
]
|
||||
if missing:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_CONTEXT_INVALID",
|
||||
"Plugin command context does not satisfy its when conditions.",
|
||||
details={"command_id": command_id, "missing": missing},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error
|
||||
scoped_context = {
|
||||
key: raw_context[key]
|
||||
for key in registered.spec.context
|
||||
if key in raw_context
|
||||
}
|
||||
try:
|
||||
effect = registered.executor(dict(arguments), scoped_context)
|
||||
if inspect.isawaitable(effect):
|
||||
effect = await asyncio.wait_for(
|
||||
effect, timeout=registered.spec.timeout_seconds
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_TIMEOUT",
|
||||
"Plugin command execution timed out.",
|
||||
status_code=504,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error from exc
|
||||
except ExtensionError as exc:
|
||||
self._record_audit(registered, started_at, exc.code)
|
||||
raise
|
||||
except Exception as exc:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_EXECUTION_FAILED",
|
||||
"Plugin command execution failed.",
|
||||
status_code=502,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error from exc
|
||||
if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES):
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_RESULT_INVALID",
|
||||
"Plugin command returned an invalid effect.",
|
||||
status_code=502,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error
|
||||
try:
|
||||
encoded_effect = json.dumps(effect.model_dump(mode="json"), ensure_ascii=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_RESULT_INVALID",
|
||||
"Plugin command returned a non-serializable effect.",
|
||||
status_code=502,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error from exc
|
||||
if len(encoded_effect.encode("utf-8")) > 64 * 1024:
|
||||
error = ExtensionError(
|
||||
"PLUGIN_COMMAND_RESULT_TOO_LARGE",
|
||||
"Plugin command effect exceeds the 64 KiB response limit.",
|
||||
status_code=502,
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
self._record_audit(registered, started_at, error.code)
|
||||
raise error
|
||||
self._record_audit(registered, started_at, None)
|
||||
return PluginCommandResult(command_id=command_id, effect=effect)
|
||||
|
||||
def _record_audit(
|
||||
self,
|
||||
registered: _RegisteredCommand,
|
||||
started_at: float,
|
||||
error_code: str | None,
|
||||
) -> None:
|
||||
event = PluginCommandAuditEvent(
|
||||
command_id=registered.command.command_id,
|
||||
plugin_id=registered.command.plugin_id,
|
||||
status="failed" if error_code else "completed",
|
||||
duration_ms=max(0, round((perf_counter() - started_at) * 1000)),
|
||||
error_code=error_code,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
with self._lock:
|
||||
self._audit.append(event)
|
||||
|
||||
|
||||
class PluginSettingsDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
section_id: str
|
||||
schema_version: int = Field(ge=1)
|
||||
fields: list[PluginSettingField] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginSettingsStore:
|
||||
"""非敏感值写入插件命名空间;Secret 只保存加密凭据引用。"""
|
||||
|
||||
def __init__(self, credentials: EncryptedCredentialStore) -> None:
|
||||
self.credentials = credentials
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _path() -> Path:
|
||||
return get_settings().data_dir / "plugins" / "settings.json"
|
||||
|
||||
def get(
|
||||
self, plugin_id: str, definition: PluginSettingsDefinition
|
||||
) -> PluginSettingsSchema:
|
||||
with self._lock:
|
||||
entry = self._entry(self._read(), plugin_id)
|
||||
stored_values = entry.get("values", {})
|
||||
secret_refs = entry.get("secret_refs", {})
|
||||
if not isinstance(stored_values, dict) or not isinstance(secret_refs, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
validated_refs = self._validate_secret_refs(plugin_id, secret_refs)
|
||||
values = {
|
||||
field.key: field.default
|
||||
for field in definition.fields
|
||||
if field.type != PluginSettingType.secret and field.default is not None
|
||||
}
|
||||
allowed_values = {
|
||||
field.key
|
||||
for field in definition.fields
|
||||
if field.type != PluginSettingType.secret
|
||||
}
|
||||
fields = {field.key: field for field in definition.fields}
|
||||
for key, value in stored_values.items():
|
||||
if key not in allowed_values:
|
||||
continue
|
||||
try:
|
||||
_validate_setting_value(fields[key], value)
|
||||
except ExtensionError as exc:
|
||||
raise self._storage_format_error(plugin_id) from exc
|
||||
values[key] = value
|
||||
secrets: dict[str, PluginSecretState] = {}
|
||||
for field in definition.fields:
|
||||
if field.type != PluginSettingType.secret:
|
||||
continue
|
||||
reference = validated_refs.get(field.key)
|
||||
secrets[field.key] = PluginSecretState(
|
||||
configured=isinstance(reference, str) and self._has_secret(reference)
|
||||
)
|
||||
return PluginSettingsSchema(
|
||||
plugin_id=plugin_id,
|
||||
schema_version=definition.schema_version,
|
||||
fields=definition.fields,
|
||||
values=values,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
def runtime_values(
|
||||
self, plugin_id: str, definition: PluginSettingsDefinition
|
||||
) -> dict[str, Any]:
|
||||
"""返回可供 Command 使用的完整普通设置,并拦截未配置的必填项。"""
|
||||
|
||||
schema = self.get(plugin_id, definition)
|
||||
missing = [
|
||||
field.key
|
||||
for field in definition.fields
|
||||
if field.required
|
||||
and field.type != PluginSettingType.secret
|
||||
and field.key not in schema.values
|
||||
]
|
||||
if missing:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_REQUIRED",
|
||||
"Required Plugin settings have not been configured.",
|
||||
status_code=409,
|
||||
details={"plugin_id": plugin_id, "fields": missing},
|
||||
)
|
||||
return schema.values
|
||||
|
||||
def update(
|
||||
self,
|
||||
plugin_id: str,
|
||||
definition: PluginSettingsDefinition,
|
||||
schema_version: int,
|
||||
values: dict[str, Any],
|
||||
) -> PluginSettingsSchema:
|
||||
if schema_version != definition.schema_version:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_VERSION_CONFLICT",
|
||||
"Plugin settings schema version is out of date.",
|
||||
status_code=409,
|
||||
details={
|
||||
"plugin_id": plugin_id,
|
||||
"requested_version": schema_version,
|
||||
"current_version": definition.schema_version,
|
||||
},
|
||||
)
|
||||
fields = {field.key: field for field in definition.fields}
|
||||
unknown = sorted(set(values) - set(fields))
|
||||
if unknown:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
"Plugin settings contain unknown fields.",
|
||||
details={"plugin_id": plugin_id, "fields": unknown},
|
||||
)
|
||||
secret_keys = sorted(
|
||||
key for key in values if fields[key].type == PluginSettingType.secret
|
||||
)
|
||||
if secret_keys:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
"Secret fields must use the dedicated Secret endpoint.",
|
||||
details={"plugin_id": plugin_id, "fields": secret_keys},
|
||||
)
|
||||
for key, value in values.items():
|
||||
_validate_setting_value(fields[key], value)
|
||||
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
entry = self._entry(data, plugin_id, create=True)
|
||||
current = entry.get("values", {})
|
||||
if not isinstance(current, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
entry["values"] = current
|
||||
current.update(values)
|
||||
effective = {
|
||||
field.key: field.default
|
||||
for field in definition.fields
|
||||
if field.type != PluginSettingType.secret and field.default is not None
|
||||
}
|
||||
effective.update(current)
|
||||
missing = [
|
||||
field.key
|
||||
for field in definition.fields
|
||||
if field.required
|
||||
and field.type != PluginSettingType.secret
|
||||
and field.key not in effective
|
||||
]
|
||||
if missing:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
"Required Plugin settings are missing.",
|
||||
details={"plugin_id": plugin_id, "fields": missing},
|
||||
)
|
||||
entry["schema_version"] = definition.schema_version
|
||||
self._write(data)
|
||||
return self.get(plugin_id, definition)
|
||||
|
||||
def put_secret(
|
||||
self,
|
||||
plugin_id: str,
|
||||
definition: PluginSettingsDefinition,
|
||||
key: str,
|
||||
secret: str,
|
||||
) -> PluginSecretStatus:
|
||||
_secret_field(definition, plugin_id, key)
|
||||
if not secret:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_VALUE_INVALID",
|
||||
"Plugin secret cannot be empty.",
|
||||
details={"plugin_id": plugin_id, "key": key},
|
||||
)
|
||||
if len(secret.encode("utf-8")) > 64 * 1024:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_VALUE_INVALID",
|
||||
"Plugin secret exceeds the 64 KiB limit.",
|
||||
details={"plugin_id": plugin_id, "key": key},
|
||||
)
|
||||
reference = _secret_reference(plugin_id, key)
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
entry = self._entry(data, plugin_id, create=True)
|
||||
refs = entry.get("secret_refs", {})
|
||||
if not isinstance(refs, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
self._validate_secret_refs(plugin_id, refs)
|
||||
entry["secret_refs"] = refs
|
||||
try:
|
||||
previous = self.credentials.resolve(reference)
|
||||
self.credentials.put(reference, secret)
|
||||
except CredentialStoreError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||
) from exc
|
||||
refs[key] = reference
|
||||
entry["schema_version"] = definition.schema_version
|
||||
try:
|
||||
self._write(data)
|
||||
except ExtensionError:
|
||||
# 普通设置落盘失败时恢复凭据旧值,避免产生不可达的新 Secret。
|
||||
try:
|
||||
if previous is None:
|
||||
self.credentials.delete(reference)
|
||||
else:
|
||||
self.credentials.put(reference, previous)
|
||||
except CredentialStoreError:
|
||||
pass
|
||||
raise
|
||||
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=True)
|
||||
|
||||
def delete_secret(
|
||||
self,
|
||||
plugin_id: str,
|
||||
definition: PluginSettingsDefinition,
|
||||
key: str,
|
||||
) -> PluginSecretStatus:
|
||||
_secret_field(definition, plugin_id, key)
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
entry = self._entry(data, plugin_id)
|
||||
refs = entry.get("secret_refs", {})
|
||||
if not isinstance(refs, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
self._validate_secret_refs(plugin_id, refs)
|
||||
reference = _secret_reference(plugin_id, key)
|
||||
had_reference = refs.pop(key, None) is not None
|
||||
if plugin_id in data and had_reference:
|
||||
self._write(data)
|
||||
try:
|
||||
self.credentials.delete(reference)
|
||||
except CredentialStoreError as exc:
|
||||
if had_reference:
|
||||
refs[key] = reference
|
||||
try:
|
||||
self._write(data)
|
||||
except ExtensionError as rollback_exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin Secret deletion failed and its reference could not be restored.",
|
||||
status_code=500,
|
||||
details={"plugin_id": plugin_id, "key": key},
|
||||
) from rollback_exc
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||
) from exc
|
||||
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=False)
|
||||
|
||||
def resolve_secret(
|
||||
self, plugin_id: str, definition: PluginSettingsDefinition, key: str
|
||||
) -> str | None:
|
||||
_secret_field(definition, plugin_id, key)
|
||||
with self._lock:
|
||||
entry = self._entry(self._read(), plugin_id)
|
||||
refs = entry.get("secret_refs", {})
|
||||
if not isinstance(refs, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
reference = self._validate_secret_refs(plugin_id, refs).get(key)
|
||||
try:
|
||||
return self.credentials.resolve(reference) if isinstance(reference, str) else None
|
||||
except CredentialStoreError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||
) from exc
|
||||
|
||||
def remove_plugin(self, plugin_id: str) -> None:
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
entry = data.pop(plugin_id, None)
|
||||
references: list[str] = []
|
||||
if entry is not None and not isinstance(entry, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
if entry is not None:
|
||||
refs = entry.get("secret_refs", {})
|
||||
if not isinstance(refs, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
references = list(self._validate_secret_refs(plugin_id, refs).values())
|
||||
if entry is not None:
|
||||
self._write(data)
|
||||
try:
|
||||
self.credentials.delete_many(references)
|
||||
except CredentialStoreError as exc:
|
||||
if entry is not None:
|
||||
data[plugin_id] = entry
|
||||
try:
|
||||
self._write(data)
|
||||
except ExtensionError as rollback_exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin uninstall failed and its Settings namespace could not be restored.",
|
||||
status_code=500,
|
||||
details={"plugin_id": plugin_id},
|
||||
) from rollback_exc
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||
) from exc
|
||||
|
||||
def _validate_secret_refs(
|
||||
self, plugin_id: str, refs: dict[Any, Any]
|
||||
) -> dict[str, str]:
|
||||
validated: dict[str, str] = {}
|
||||
for key, reference in refs.items():
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or not _SETTING_KEY.fullmatch(key)
|
||||
or not isinstance(reference, str)
|
||||
or reference != _secret_reference(plugin_id, key)
|
||||
):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
validated[key] = reference
|
||||
return validated
|
||||
|
||||
def _has_secret(self, reference: str) -> bool:
|
||||
try:
|
||||
return self.credentials.has(reference)
|
||||
except CredentialStoreError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _storage_format_error(plugin_id: str) -> ExtensionError:
|
||||
return ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin settings namespace has an invalid format.",
|
||||
status_code=500,
|
||||
details={"plugin_id": plugin_id},
|
||||
)
|
||||
|
||||
def _entry(
|
||||
self,
|
||||
data: dict[str, dict[str, Any]],
|
||||
plugin_id: str,
|
||||
*,
|
||||
create: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
entry = data.get(plugin_id)
|
||||
if entry is None:
|
||||
if create:
|
||||
data[plugin_id] = {}
|
||||
return data[plugin_id]
|
||||
return {}
|
||||
if not isinstance(entry, dict):
|
||||
raise self._storage_format_error(plugin_id)
|
||||
return entry
|
||||
|
||||
def _read(self) -> dict[str, dict[str, Any]]:
|
||||
path = self._path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin settings storage cannot be loaded.",
|
||||
status_code=500,
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin settings storage has an invalid format.",
|
||||
status_code=500,
|
||||
)
|
||||
return value
|
||||
|
||||
def _write(self, value: dict[str, dict[str, Any]]) -> None:
|
||||
path = self._path()
|
||||
temporary = path.with_suffix(".tmp")
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError as exc:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise ExtensionError(
|
||||
"PLUGIN_STORAGE_ERROR",
|
||||
"Plugin settings storage cannot be written.",
|
||||
status_code=500,
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_settings_definition(
|
||||
plugin_id: str, definition: PluginSettingsDefinition
|
||||
) -> None:
|
||||
if not _CONTRIBUTION_ID.fullmatch(definition.section_id):
|
||||
raise _settings_schema_error(plugin_id, "Settings section id is invalid.")
|
||||
if not definition.section_id.startswith(f"{plugin_id}."):
|
||||
raise _settings_schema_error(
|
||||
plugin_id, "Settings section id must use the Plugin namespace."
|
||||
)
|
||||
keys: set[str] = set()
|
||||
for field in definition.fields:
|
||||
if not _SETTING_KEY.fullmatch(field.key) or field.key in keys:
|
||||
raise _settings_schema_error(plugin_id, f"Invalid or duplicate setting key: {field.key}")
|
||||
keys.add(field.key)
|
||||
if field.type == PluginSettingType.select and not field.options:
|
||||
raise _settings_schema_error(plugin_id, f"Select setting requires options: {field.key}")
|
||||
if field.type != PluginSettingType.select and field.options:
|
||||
raise _settings_schema_error(plugin_id, f"Only select settings accept options: {field.key}")
|
||||
if field.type != PluginSettingType.number and (
|
||||
field.minimum is not None or field.maximum is not None
|
||||
):
|
||||
raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}")
|
||||
if any(
|
||||
bound is not None and not math.isfinite(bound)
|
||||
for bound in (field.minimum, field.maximum)
|
||||
):
|
||||
raise _settings_schema_error(
|
||||
plugin_id, f"Number setting bounds must be finite: {field.key}"
|
||||
)
|
||||
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
|
||||
raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}")
|
||||
if field.type == PluginSettingType.secret and field.default is not None:
|
||||
raise _settings_schema_error(plugin_id, f"Secret settings cannot declare defaults: {field.key}")
|
||||
if field.default is not None:
|
||||
try:
|
||||
_validate_setting_value(field, field.default)
|
||||
except ExtensionError as exc:
|
||||
raise _settings_schema_error(plugin_id, exc.message) from exc
|
||||
|
||||
|
||||
def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
|
||||
if not _CONTRIBUTION_ID.fullmatch(spec.command_id) or not spec.command_id.startswith(
|
||||
f"{plugin_id}."
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command id must be valid and use the Plugin namespace.",
|
||||
details={"plugin_id": plugin_id, "command_id": spec.command_id},
|
||||
)
|
||||
if not spec.locations:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command must declare at least one location.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if len(spec.locations) != len(set(spec.locations)):
|
||||
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command locations must be unique.")
|
||||
if len(spec.when) != len(set(spec.when)) or len(spec.context) != len(set(spec.context)):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command when/context entries must be unique.",
|
||||
)
|
||||
if len(spec.secrets) != len(set(spec.secrets)):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command Secret entries must be unique.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
unknown_when = sorted(set(spec.when) - _WHEN_TOKENS)
|
||||
if unknown_when:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command declares unsupported when tokens.",
|
||||
details={"command_id": spec.command_id, "when": unknown_when},
|
||||
)
|
||||
required_context = {_WHEN_CONTEXT[token] for token in spec.when}
|
||||
if not required_context.issubset(set(spec.context)):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command context must include every field required by when.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if not set(spec.context).issubset(_CONTEXT_KEYS):
|
||||
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command context is invalid.")
|
||||
if spec.icon and spec.icon not in _HOST_ICONS:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command icon is not a supported Host icon.",
|
||||
details={"command_id": spec.command_id, "icon": spec.icon},
|
||||
)
|
||||
if spec.parameters.get("type", "object") != "object":
|
||||
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
|
||||
try:
|
||||
Draft202012Validator.check_schema(spec.parameters)
|
||||
reject_external_schema_references(spec.parameters)
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
f"Plugin command parameters contain invalid JSON Schema: {message}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _validate_setting_value(field: PluginSettingField, value: Any) -> None:
|
||||
valid = False
|
||||
if field.type == PluginSettingType.string:
|
||||
valid = isinstance(value, str) and len(value.encode("utf-8")) <= 64 * 1024
|
||||
elif field.type == PluginSettingType.number:
|
||||
valid = (
|
||||
(isinstance(value, int) and not isinstance(value, bool))
|
||||
or (isinstance(value, float) and math.isfinite(value))
|
||||
)
|
||||
elif field.type == PluginSettingType.boolean:
|
||||
valid = isinstance(value, bool)
|
||||
elif field.type == PluginSettingType.select:
|
||||
valid = isinstance(value, str) and value in field.options
|
||||
if not valid:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
f"Plugin setting has an invalid value: {field.key}",
|
||||
details={"key": field.key},
|
||||
)
|
||||
if field.type == PluginSettingType.number:
|
||||
if field.minimum is not None and value < field.minimum:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
f"Plugin setting is below its minimum: {field.key}",
|
||||
details={"key": field.key, "minimum": field.minimum},
|
||||
)
|
||||
if field.maximum is not None and value > field.maximum:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_FIELD_INVALID",
|
||||
f"Plugin setting is above its maximum: {field.key}",
|
||||
details={"key": field.key, "maximum": field.maximum},
|
||||
)
|
||||
|
||||
|
||||
def _secret_field(
|
||||
definition: PluginSettingsDefinition, plugin_id: str, key: str
|
||||
) -> PluginSettingField:
|
||||
field = next((item for item in definition.fields if item.key == key), None)
|
||||
if field is None or field.type != PluginSettingType.secret:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_FIELD_NOT_FOUND",
|
||||
f"Plugin secret field does not exist: {key}",
|
||||
status_code=404,
|
||||
details={"plugin_id": plugin_id, "key": key},
|
||||
)
|
||||
return field
|
||||
|
||||
|
||||
def _secret_reference(plugin_id: str, key: str) -> str:
|
||||
digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest()
|
||||
return f"plugin.{digest}"
|
||||
|
||||
|
||||
def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError:
|
||||
return ExtensionError(
|
||||
"PLUGIN_SETTINGS_SCHEMA_INVALID",
|
||||
message,
|
||||
details={"plugin_id": plugin_id},
|
||||
)
|
||||
@@ -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 {}
|
||||
+795
-50
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,40 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
|
||||
from jsonschema.exceptions import (
|
||||
SchemaError,
|
||||
ValidationError as JsonSchemaValidationError,
|
||||
)
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
create_model,
|
||||
)
|
||||
|
||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||
from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
|
||||
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||
from app.contracts import (
|
||||
ModelCapability,
|
||||
Plugin,
|
||||
PluginCommand,
|
||||
PluginCommandContext,
|
||||
PluginCommandEffect,
|
||||
PluginNoEffect,
|
||||
PluginNotificationEffect,
|
||||
PluginCommandLocation,
|
||||
PluginCommandResult,
|
||||
PluginManifest,
|
||||
PluginHostStatus,
|
||||
PluginSecretStatus,
|
||||
PluginSettingType,
|
||||
PluginSettingsSchema,
|
||||
PluginStatus,
|
||||
RetrievalConfig,
|
||||
Skill,
|
||||
@@ -25,27 +46,26 @@ from app.contracts import (
|
||||
SkillStatus,
|
||||
ToolDefinition,
|
||||
)
|
||||
from app.extensions.contributions import (
|
||||
CommandRegistry,
|
||||
PluginCommandSpec,
|
||||
PluginSecretResolver,
|
||||
PluginSettingsDefinition,
|
||||
PluginSettingsStore,
|
||||
validate_command_spec,
|
||||
validate_settings_definition,
|
||||
)
|
||||
from app.extensions.errors import ExtensionError
|
||||
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
|
||||
from app.providers.credentials import EncryptedCredentialStore
|
||||
from app.schema_security import (
|
||||
SchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
|
||||
class ExtensionError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 422,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentConfiguration:
|
||||
skill_id: str
|
||||
@@ -238,14 +258,45 @@ class DeclarativePluginHost:
|
||||
return {"text": str(values.get("text", "")).upper()}
|
||||
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
handler: str,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
settings: dict[str, Any],
|
||||
resolve_secret: PluginSecretResolver,
|
||||
) -> PluginCommandEffect:
|
||||
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
|
||||
|
||||
if handler == "echo":
|
||||
message = str(arguments.get("message", context.get("selection", "")))
|
||||
if not message:
|
||||
return PluginNoEffect()
|
||||
return PluginNotificationEffect(
|
||||
payload={"level": "info", "message": message},
|
||||
)
|
||||
if handler == "uppercase_selection":
|
||||
text = str(arguments.get("text", context.get("selection", "")))
|
||||
limit = int(settings.get("result_limit", 100))
|
||||
return PluginNotificationEffect(
|
||||
payload={"level": "success", "message": text[:limit].upper()},
|
||||
)
|
||||
raise ExtensionError(
|
||||
"PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported command handler: {handler}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PluginRecord:
|
||||
plugin: Plugin
|
||||
tools: list[DeclarativeToolSpec]
|
||||
commands: list[PluginCommandSpec]
|
||||
settings_definition: PluginSettingsDefinition | None
|
||||
package_path: Path
|
||||
registered_tools: list[str]
|
||||
registered_commands: list[str]
|
||||
mcp_remote_names: dict[str, str]
|
||||
mcp_command_schemas: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
class PluginRuntime:
|
||||
@@ -256,12 +307,15 @@ class PluginRuntime:
|
||||
tools: ToolRegistry,
|
||||
host: DeclarativePluginHost | None = None,
|
||||
mcp_bridge: McpBridge | None = None,
|
||||
credentials: EncryptedCredentialStore | None = None,
|
||||
*,
|
||||
allow_unsandboxed_mcp: bool = False,
|
||||
) -> None:
|
||||
self.registry = tools
|
||||
self.host = host or DeclarativePluginHost()
|
||||
self.mcp = mcp_bridge or McpBridge()
|
||||
self.commands = CommandRegistry()
|
||||
self.settings = PluginSettingsStore(credentials or EncryptedCredentialStore())
|
||||
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
|
||||
self._records: dict[str, _PluginRecord] = {}
|
||||
self._lock = threading.RLock()
|
||||
@@ -287,6 +341,8 @@ class PluginRuntime:
|
||||
|
||||
_validate_backend(manifest)
|
||||
specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
|
||||
command_specs = self._load_commands(root)
|
||||
settings_definition = self._load_settings(root)
|
||||
if manifest.backend.type != "mcp":
|
||||
declared = set(manifest.contributes.tools)
|
||||
actual = {spec.name for spec in specs}
|
||||
@@ -305,6 +361,89 @@ class PluginRuntime:
|
||||
f"Tool permission is not declared by Plugin: {spec.permission}",
|
||||
details={"tool": spec.name, "permission": spec.permission},
|
||||
)
|
||||
declared_commands = set(manifest.contributes.commands)
|
||||
actual_commands = {spec.command_id for spec in command_specs}
|
||||
if (
|
||||
declared_commands != actual_commands
|
||||
or len(manifest.contributes.commands) != len(declared_commands)
|
||||
or len(command_specs) != len(actual_commands)
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"plugin.yaml command contributions must exactly match commands.yaml",
|
||||
details={
|
||||
"declared": sorted(declared_commands),
|
||||
"actual": sorted(actual_commands),
|
||||
},
|
||||
)
|
||||
for spec in command_specs:
|
||||
validate_command_spec(manifest.plugin_id, spec)
|
||||
if spec.permission and spec.permission not in manifest.permissions:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_PERMISSION_UNDECLARED",
|
||||
f"Command permission is not declared by Plugin: {spec.permission}",
|
||||
details={"command": spec.command_id, "permission": spec.permission},
|
||||
)
|
||||
declared_sections = set(manifest.contributes.settings_sections)
|
||||
actual_sections = (
|
||||
{settings_definition.section_id} if settings_definition is not None else set()
|
||||
)
|
||||
if (
|
||||
declared_sections != actual_sections
|
||||
or len(manifest.contributes.settings_sections) != len(declared_sections)
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"plugin.yaml settings contributions must exactly match settings.yaml",
|
||||
details={
|
||||
"declared": sorted(declared_sections),
|
||||
"actual": sorted(actual_sections),
|
||||
},
|
||||
)
|
||||
if settings_definition is not None:
|
||||
validate_settings_definition(manifest.plugin_id, settings_definition)
|
||||
secret_fields = (
|
||||
{
|
||||
field.key
|
||||
for field in settings_definition.fields
|
||||
if field.type == PluginSettingType.secret
|
||||
}
|
||||
if settings_definition is not None
|
||||
else set()
|
||||
)
|
||||
for spec in command_specs:
|
||||
unknown_secrets = sorted(set(spec.secrets) - secret_fields)
|
||||
if unknown_secrets:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"Plugin command references undeclared Secret settings.",
|
||||
details={
|
||||
"command_id": spec.command_id,
|
||||
"secrets": unknown_secrets,
|
||||
},
|
||||
)
|
||||
if spec.secrets and "secrets.use" not in manifest.permissions:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_PERMISSION_UNDECLARED",
|
||||
"Commands using Secret settings require the secrets.use permission.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if spec.mcp_tool is not None:
|
||||
_validate_id("MCP command target", spec.mcp_tool)
|
||||
if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith(
|
||||
f"{manifest.plugin_id}."
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"MCP Command target must use the current Plugin namespace.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if spec.mcp_tool in manifest.contributes.tools:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"MCP Command target cannot also be exposed as an Agent Tool.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
|
||||
record = _PluginRecord(
|
||||
plugin=Plugin(
|
||||
@@ -316,9 +455,13 @@ class PluginRuntime:
|
||||
),
|
||||
),
|
||||
tools=specs,
|
||||
commands=command_specs,
|
||||
settings_definition=settings_definition,
|
||||
package_path=root,
|
||||
registered_tools=[],
|
||||
registered_commands=[],
|
||||
mcp_remote_names={},
|
||||
mcp_command_schemas={},
|
||||
)
|
||||
self._records[manifest.plugin_id] = record
|
||||
return record.plugin.model_copy(deep=True)
|
||||
@@ -359,6 +502,8 @@ class PluginRuntime:
|
||||
status_code=403,
|
||||
details={"plugin_id": plugin_id},
|
||||
)
|
||||
if record.settings_definition is not None:
|
||||
self.settings.runtime_values(plugin_id, record.settings_definition)
|
||||
declared_tools = list(record.plugin.manifest.contributes.tools)
|
||||
conflicts = [name for name in declared_tools if self.registry.contains(name)]
|
||||
if conflicts:
|
||||
@@ -368,20 +513,49 @@ class PluginRuntime:
|
||||
status_code=409,
|
||||
details={"plugin_id": plugin_id, "tools": conflicts},
|
||||
)
|
||||
command_conflicts = [
|
||||
spec.command_id for spec in record.commands if self.commands.contains(spec.command_id)
|
||||
]
|
||||
if command_conflicts:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_CONFLICT",
|
||||
"Plugin commands are already registered.",
|
||||
status_code=409,
|
||||
details={"plugin_id": plugin_id, "commands": command_conflicts},
|
||||
)
|
||||
record.plugin.status = PluginStatus.starting
|
||||
try:
|
||||
if record.plugin.manifest.backend.type == "mcp":
|
||||
discovered = self._start_mcp(record)
|
||||
actual = {item.definition.name for item in discovered}
|
||||
declared = set(declared_tools)
|
||||
if actual != declared:
|
||||
command_targets = {
|
||||
spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None
|
||||
}
|
||||
expected = declared | command_targets
|
||||
if actual != expected:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"Discovered MCP tools must exactly match Plugin contributions.",
|
||||
details={"declared": sorted(declared), "actual": sorted(actual)},
|
||||
"Discovered MCP tools must exactly match Tool and Command targets.",
|
||||
details={"declared": sorted(expected), "actual": sorted(actual)},
|
||||
)
|
||||
for item in discovered:
|
||||
self._register_mcp_tool(record, item)
|
||||
if item.definition.name in declared:
|
||||
self._register_mcp_tool(record, item)
|
||||
else:
|
||||
record.mcp_remote_names[item.definition.name] = item.remote_name
|
||||
record.mcp_command_schemas[item.definition.name] = (
|
||||
item.definition.parameters
|
||||
)
|
||||
for spec in (
|
||||
command
|
||||
for command in record.commands
|
||||
if command.mcp_tool == item.definition.name
|
||||
):
|
||||
_validate_mcp_command_target_schema(
|
||||
item.definition.parameters,
|
||||
spec.command_id,
|
||||
)
|
||||
else:
|
||||
for spec in record.tools:
|
||||
arguments_model = _arguments_model(spec)
|
||||
@@ -405,12 +579,135 @@ class PluginRuntime:
|
||||
executor,
|
||||
)
|
||||
record.registered_tools.append(spec.name)
|
||||
for spec in record.commands:
|
||||
|
||||
async def command_executor(
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
_spec: PluginCommandSpec = spec,
|
||||
_record: _PluginRecord = record,
|
||||
) -> PluginCommandEffect:
|
||||
if (
|
||||
not _record.plugin.enabled
|
||||
or _record.plugin.status != PluginStatus.ready
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_NOT_FOUND",
|
||||
"Plugin command is not available while its Plugin is inactive.",
|
||||
status_code=404,
|
||||
details={"command_id": _spec.command_id},
|
||||
)
|
||||
settings = (
|
||||
self.settings.runtime_values(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
_record.settings_definition,
|
||||
)
|
||||
if _record.settings_definition is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
def resolve_secret(key: str) -> str | None:
|
||||
if key not in _spec.secrets:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_ACCESS_DENIED",
|
||||
"Command cannot access an undeclared Plugin Secret.",
|
||||
status_code=403,
|
||||
details={
|
||||
"command_id": _spec.command_id,
|
||||
"key": key,
|
||||
},
|
||||
)
|
||||
if "secrets.use" not in _record.plugin.granted_permissions:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_ACCESS_DENIED",
|
||||
"Plugin no longer has permission to access Secret settings.",
|
||||
status_code=403,
|
||||
details={"command_id": _spec.command_id, "key": key},
|
||||
)
|
||||
if _record.settings_definition is None:
|
||||
return None
|
||||
value = self.settings.resolve_secret(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
_record.settings_definition,
|
||||
key,
|
||||
)
|
||||
field = next(
|
||||
item
|
||||
for item in _record.settings_definition.fields
|
||||
if item.key == key
|
||||
)
|
||||
if field.required and value is None:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_REQUIRED",
|
||||
"A required Plugin Secret has not been configured.",
|
||||
status_code=409,
|
||||
details={"command_id": _spec.command_id, "key": key},
|
||||
)
|
||||
return value
|
||||
|
||||
if _spec.mcp_tool is not None:
|
||||
remote_name = _record.mcp_remote_names[_spec.mcp_tool]
|
||||
secret_values = {
|
||||
key: value
|
||||
for key in _spec.secrets
|
||||
if (value := resolve_secret(key)) is not None
|
||||
}
|
||||
envelope = _mcp_command_envelope(
|
||||
_spec,
|
||||
arguments=arguments,
|
||||
context=context,
|
||||
settings=settings,
|
||||
secrets=secret_values,
|
||||
)
|
||||
_validate_mcp_command_envelope(
|
||||
_record.mcp_command_schemas[_spec.mcp_tool],
|
||||
envelope,
|
||||
_spec.command_id,
|
||||
)
|
||||
try:
|
||||
effect = await self.mcp.call_tool(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
remote_name,
|
||||
envelope,
|
||||
request_id=f"command:{uuid4().hex}",
|
||||
)
|
||||
except ToolExecutionError as exc:
|
||||
raise ExtensionError(
|
||||
exc.code,
|
||||
"MCP Command target execution failed.",
|
||||
status_code=502,
|
||||
details={"command_id": _spec.command_id},
|
||||
) from exc
|
||||
try:
|
||||
return TypeAdapter(PluginCommandEffect).validate_python(effect)
|
||||
except ValidationError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_RESULT_INVALID",
|
||||
"MCP Command target returned an invalid effect.",
|
||||
status_code=502,
|
||||
details={"command_id": _spec.command_id},
|
||||
) from exc
|
||||
|
||||
return await self.host.execute_command(
|
||||
_spec.handler,
|
||||
arguments,
|
||||
context,
|
||||
settings,
|
||||
resolve_secret,
|
||||
)
|
||||
|
||||
self.commands.register(plugin_id, spec, command_executor)
|
||||
record.registered_commands.append(spec.command_id)
|
||||
except Exception as exc:
|
||||
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
for command_id in record.registered_commands:
|
||||
self.commands.unregister(command_id)
|
||||
record.registered_commands.clear()
|
||||
record.mcp_remote_names.clear()
|
||||
record.mcp_command_schemas.clear()
|
||||
self.mcp.stop(plugin_id)
|
||||
record.plugin.status = PluginStatus.error
|
||||
record.plugin.error_message = _safe_extension_message(exc)
|
||||
@@ -468,7 +765,11 @@ class PluginRuntime:
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
for command_id in record.registered_commands:
|
||||
self.commands.unregister(command_id)
|
||||
record.registered_commands.clear()
|
||||
record.mcp_remote_names.clear()
|
||||
record.mcp_command_schemas.clear()
|
||||
if record.plugin.manifest.backend.type == "mcp":
|
||||
self.mcp.stop(plugin_id)
|
||||
record.plugin.enabled = False
|
||||
@@ -479,6 +780,43 @@ class PluginRuntime:
|
||||
record = self._record(plugin_id)
|
||||
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
|
||||
|
||||
def list_commands(
|
||||
self, location: PluginCommandLocation | None = None
|
||||
) -> list[PluginCommand]:
|
||||
return self.commands.list(location)
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
command_id: str,
|
||||
arguments: dict[str, Any],
|
||||
context: PluginCommandContext,
|
||||
) -> PluginCommandResult:
|
||||
return await self.commands.execute(command_id, arguments, context)
|
||||
|
||||
def get_settings(self, plugin_id: str) -> PluginSettingsSchema:
|
||||
record = self._record(plugin_id)
|
||||
definition = self._settings_definition(record)
|
||||
return self.settings.get(plugin_id, definition)
|
||||
|
||||
def update_settings(
|
||||
self, plugin_id: str, schema_version: int, values: dict[str, Any]
|
||||
) -> PluginSettingsSchema:
|
||||
record = self._record(plugin_id)
|
||||
definition = self._settings_definition(record)
|
||||
return self.settings.update(plugin_id, definition, schema_version, values)
|
||||
|
||||
def put_setting_secret(
|
||||
self, plugin_id: str, key: str, secret: str
|
||||
) -> PluginSecretStatus:
|
||||
record = self._record(plugin_id)
|
||||
definition = self._settings_definition(record)
|
||||
return self.settings.put_secret(plugin_id, definition, key, secret)
|
||||
|
||||
def delete_setting_secret(self, plugin_id: str, key: str) -> PluginSecretStatus:
|
||||
record = self._record(plugin_id)
|
||||
definition = self._settings_definition(record)
|
||||
return self.settings.delete_secret(plugin_id, definition, key)
|
||||
|
||||
def restart_host(self, plugin_id: str) -> PluginHostStatus:
|
||||
with self._lock:
|
||||
return self._restart_host(plugin_id)
|
||||
@@ -506,7 +844,11 @@ class PluginRuntime:
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
for command_id in record.registered_commands:
|
||||
self.commands.unregister(command_id)
|
||||
record.registered_commands.clear()
|
||||
record.mcp_remote_names.clear()
|
||||
record.mcp_command_schemas.clear()
|
||||
self.mcp.stop(plugin_id)
|
||||
record.plugin.enabled = False
|
||||
record.plugin.status = PluginStatus.installed
|
||||
@@ -567,7 +909,11 @@ class PluginRuntime:
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
for command_id in record.registered_commands:
|
||||
self.commands.unregister(command_id)
|
||||
record.registered_commands.clear()
|
||||
record.mcp_remote_names.clear()
|
||||
record.mcp_command_schemas.clear()
|
||||
record.plugin.enabled = False
|
||||
record.plugin.status = PluginStatus.error
|
||||
record.plugin.error_message = message
|
||||
@@ -594,6 +940,7 @@ class PluginRuntime:
|
||||
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
|
||||
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
|
||||
self.mcp.remove(plugin_id)
|
||||
self.settings.remove_plugin(plugin_id)
|
||||
del self._records[plugin_id]
|
||||
|
||||
def _record(self, plugin_id: str) -> _PluginRecord:
|
||||
@@ -615,6 +962,52 @@ class PluginRuntime:
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("plugin tool", exc) from exc
|
||||
|
||||
@staticmethod
|
||||
def _load_commands(root: Path) -> list[PluginCommandSpec]:
|
||||
path = root / "commands.yaml"
|
||||
if not path.exists():
|
||||
return []
|
||||
raw = _read_yaml(path)
|
||||
items = raw.get("commands", [])
|
||||
if not isinstance(items, list):
|
||||
raise ExtensionError(
|
||||
"EXTENSION_MANIFEST_INVALID",
|
||||
"Invalid plugin command manifest: commands must be an array.",
|
||||
)
|
||||
try:
|
||||
return [
|
||||
PluginCommandSpec.model_validate(item)
|
||||
for item in items
|
||||
]
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("plugin command", exc) from exc
|
||||
|
||||
@staticmethod
|
||||
def _load_settings(root: Path) -> PluginSettingsDefinition | None:
|
||||
path = root / "settings.yaml"
|
||||
if not path.exists():
|
||||
return None
|
||||
raw = _read_yaml(path)
|
||||
try:
|
||||
return PluginSettingsDefinition.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_SCHEMA_INVALID",
|
||||
"Invalid Plugin settings schema.",
|
||||
details={"errors": exc.errors(include_url=False)},
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _settings_definition(record: _PluginRecord) -> PluginSettingsDefinition:
|
||||
if record.settings_definition is None:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SETTINGS_NOT_FOUND",
|
||||
"Plugin does not contribute a Settings section.",
|
||||
status_code=404,
|
||||
details={"plugin_id": record.plugin.manifest.plugin_id},
|
||||
)
|
||||
return record.settings_definition
|
||||
|
||||
|
||||
def _package_dir(package_path: str | Path) -> Path:
|
||||
root = Path(package_path).expanduser().resolve()
|
||||
@@ -673,6 +1066,61 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
|
||||
return _arguments_model_from_schema(spec.name, schema)
|
||||
|
||||
|
||||
def _mcp_command_envelope(
|
||||
spec: PluginCommandSpec,
|
||||
*,
|
||||
arguments: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
settings: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"_notesagent": {
|
||||
"command_id": spec.command_id,
|
||||
"arguments": arguments,
|
||||
"context": context,
|
||||
"settings": settings,
|
||||
"secrets": secrets,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _validate_mcp_command_envelope(
|
||||
schema: dict[str, Any],
|
||||
envelope: dict[str, Any],
|
||||
command_id: str,
|
||||
) -> None:
|
||||
"""执行前用目标 Tool Schema 校验包含真实业务数据的宿主信封。"""
|
||||
|
||||
try:
|
||||
Draft202012Validator(schema).validate(envelope)
|
||||
except JsonSchemaValidationError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH",
|
||||
"MCP Command envelope does not match the target inputSchema.",
|
||||
status_code=502,
|
||||
details={"command_id": command_id, "path": list(exc.path)},
|
||||
) from exc
|
||||
|
||||
|
||||
def _validate_mcp_command_target_schema(
|
||||
schema: dict[str, Any], command_id: str
|
||||
) -> None:
|
||||
"""启用时只检查稳定信封入口,避免用伪造业务值误判合法 Schema。"""
|
||||
|
||||
properties = schema.get("properties")
|
||||
envelope_schema = (
|
||||
properties.get("_notesagent") if isinstance(properties, dict) else None
|
||||
)
|
||||
if not isinstance(envelope_schema, dict) or envelope_schema.get("type") != "object":
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"MCP Command target inputSchema must directly declare "
|
||||
"_notesagent with type object.",
|
||||
details={"command_id": command_id},
|
||||
)
|
||||
|
||||
|
||||
def _arguments_model_from_schema(
|
||||
tool_name: str, schema: dict[str, Any]
|
||||
) -> type[BaseModel]:
|
||||
@@ -688,10 +1136,12 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
||||
schema = spec.parameters or {"type": "object", "properties": {}}
|
||||
try:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
reject_external_schema_references(schema)
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
"PLUGIN_TOOL_SCHEMA_INVALID",
|
||||
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
|
||||
f"Invalid JSON Schema for tool {spec.name}: {message}",
|
||||
details={"tool": spec.name},
|
||||
) from exc
|
||||
if schema.get("type", "object") != "object" or not isinstance(
|
||||
|
||||
@@ -19,6 +19,7 @@ async def lifespan(_: FastAPI):
|
||||
yield
|
||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
@@ -5,14 +5,15 @@ import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
|
||||
_MCP_CREDENTIAL_PREFIX = "mcp."
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
@@ -23,10 +24,21 @@ class CredentialResolver(Protocol):
|
||||
def resolve(self, credential_id: str | None) -> str | None: ...
|
||||
|
||||
|
||||
def validate_provider_credential_id(credential_id: str | None) -> None:
|
||||
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
|
||||
|
||||
if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX):
|
||||
raise CredentialStoreError(
|
||||
"Credential namespace is reserved for Plugin settings."
|
||||
)
|
||||
if credential_id and credential_id.casefold().startswith(_MCP_CREDENTIAL_PREFIX):
|
||||
raise CredentialStoreError("Credential namespace is reserved for MCP settings.")
|
||||
|
||||
|
||||
class EnvironmentCredentialResolver:
|
||||
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
|
||||
|
||||
_development_aliases = {
|
||||
_development_aliases: ClassVar[dict[str, str]] = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
}
|
||||
@@ -74,7 +86,9 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
return Fernet(environment_key.encode("ascii"))
|
||||
except (ValueError, UnicodeEncodeError) as exc:
|
||||
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
|
||||
raise CredentialStoreError(
|
||||
"APP_CREDENTIAL_MASTER_KEY is invalid."
|
||||
) from exc
|
||||
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(key_path.parent, 0o700)
|
||||
@@ -91,7 +105,9 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
|
||||
raise CredentialStoreError(
|
||||
"Credential master key cannot be loaded."
|
||||
) from exc
|
||||
|
||||
def _read_tokens(self) -> dict[str, str]:
|
||||
_, store_path = self._paths()
|
||||
@@ -100,26 +116,40 @@ class EncryptedCredentialStore:
|
||||
try:
|
||||
data = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
|
||||
raise CredentialStoreError(
|
||||
"Encrypted credential store cannot be loaded."
|
||||
) from exc
|
||||
if not isinstance(data, dict) or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
||||
isinstance(key, str) and isinstance(value, str)
|
||||
for key, value in data.items()
|
||||
):
|
||||
raise CredentialStoreError("Encrypted credential store has an invalid format.")
|
||||
raise CredentialStoreError(
|
||||
"Encrypted credential store has an invalid format."
|
||||
)
|
||||
return data
|
||||
|
||||
def _write_tokens(self, tokens: dict[str, str]) -> None:
|
||||
_, store_path = self._paths()
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(store_path.parent, 0o700)
|
||||
temporary = store_path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restrict(temporary, 0o600)
|
||||
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
|
||||
temporary.replace(store_path)
|
||||
self._restrict(store_path, 0o600)
|
||||
try:
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(store_path.parent, 0o700)
|
||||
temporary.write_text(
|
||||
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restrict(temporary, 0o600)
|
||||
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
|
||||
temporary.replace(store_path)
|
||||
self._restrict(store_path, 0o600)
|
||||
except OSError as exc:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise CredentialStoreError(
|
||||
"Encrypted credential store cannot be written."
|
||||
) from exc
|
||||
|
||||
def put(self, credential_id: str, secret: str) -> None:
|
||||
self._validate_id(credential_id)
|
||||
@@ -158,6 +188,40 @@ class EncryptedCredentialStore:
|
||||
self._write_tokens(tokens)
|
||||
return removed
|
||||
|
||||
def delete_many(self, credential_ids: list[str]) -> set[str]:
|
||||
"""用一次原子替换删除多个凭据,避免插件卸载只删除部分 Secret。"""
|
||||
|
||||
for credential_id in credential_ids:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
removed = {
|
||||
credential_id
|
||||
for credential_id in credential_ids
|
||||
if credential_id in tokens
|
||||
}
|
||||
if removed:
|
||||
for credential_id in removed:
|
||||
del tokens[credential_id]
|
||||
self._write_tokens(tokens)
|
||||
return removed
|
||||
|
||||
def move_many(self, replacements: dict[str, str]) -> None:
|
||||
"""原子迁移凭据 ID,直接移动密文且不覆盖已经写入的新凭据。"""
|
||||
|
||||
for old_id, new_id in replacements.items():
|
||||
self._validate_id(old_id)
|
||||
self._validate_id(new_id)
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
changed = False
|
||||
for old_id, new_id in replacements.items():
|
||||
if old_id != new_id and old_id in tokens:
|
||||
tokens.setdefault(new_id, tokens.pop(old_id))
|
||||
changed = True
|
||||
if changed:
|
||||
self._write_tokens(tokens)
|
||||
|
||||
|
||||
class ChainedCredentialResolver:
|
||||
def __init__(self, *resolvers: CredentialResolver) -> None:
|
||||
@@ -170,3 +234,14 @@ class ChainedCredentialResolver:
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class ProviderCredentialResolver:
|
||||
"""Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。"""
|
||||
|
||||
def __init__(self, delegate: CredentialResolver) -> None:
|
||||
self._delegate = delegate
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
validate_provider_credential_id(credential_id)
|
||||
return self._delegate.resolve(credential_id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
|
||||
from app.providers.base import ModelProvider
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.credentials import CredentialResolver, ProviderCredentialResolver
|
||||
from app.providers.ollama import OllamaProvider
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
@@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError):
|
||||
|
||||
class ProviderFactory:
|
||||
def __init__(self, credentials: CredentialResolver) -> None:
|
||||
self.credentials = credentials
|
||||
# ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止
|
||||
# Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。
|
||||
self.credentials = ProviderCredentialResolver(credentials)
|
||||
|
||||
def build(self, config: ProviderConfig) -> ModelProvider:
|
||||
if config.provider_type in {
|
||||
|
||||
+280
-20
@@ -6,6 +6,8 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
from app.contracts import (
|
||||
AgentRun,
|
||||
AgentRunCreateRequest,
|
||||
@@ -29,6 +31,14 @@ from app.contracts import (
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
IndexStatus,
|
||||
McpServer,
|
||||
McpServerCreateRequest,
|
||||
McpServerListResponse,
|
||||
McpServerSecretStatus,
|
||||
McpServerSecretWriteRequest,
|
||||
McpServerTrustRequest,
|
||||
McpServerUpdateRequest,
|
||||
McpToolSummaryListResponse,
|
||||
ModelEvent,
|
||||
ModelEventType,
|
||||
Note,
|
||||
@@ -41,9 +51,17 @@ from app.contracts import (
|
||||
PageMeta,
|
||||
PermissionDecisionRequest,
|
||||
Plugin,
|
||||
PluginCommandExecuteRequest,
|
||||
PluginCommandListResponse,
|
||||
PluginCommandLocation,
|
||||
PluginCommandResult,
|
||||
PluginHostStatus,
|
||||
PluginListResponse,
|
||||
PluginPermissionGrantRequest,
|
||||
PluginSecretStatus,
|
||||
PluginSecretWriteRequest,
|
||||
PluginSettingsSchema,
|
||||
PluginSettingsUpdateRequest,
|
||||
ProviderConfig,
|
||||
ProviderCreateRequest,
|
||||
ProviderListResponse,
|
||||
@@ -74,10 +92,14 @@ from app.benchmarks import service as benchmark_service
|
||||
from app.container import container
|
||||
from app.errors import ApiError
|
||||
from app.extensions import ExtensionError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
from app.extensions.mcp_registry import McpRegistryError
|
||||
from app.providers.base import ProviderError
|
||||
from app.providers.credentials import CredentialStoreError
|
||||
from app.providers.credentials import (
|
||||
CredentialStoreError,
|
||||
validate_provider_credential_id,
|
||||
)
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.retrieval.engine import engine
|
||||
from app.services import (
|
||||
index_service,
|
||||
@@ -90,10 +112,25 @@ from app.services import (
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
async def mcp_call_async(operation):
|
||||
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
|
||||
try:
|
||||
return await asyncio.to_thread(operation)
|
||||
except McpRegistryError as exc:
|
||||
raise ApiError(exc.status_code, exc.code, exc.message) from exc
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def validate_public_credential_id(credential_id: str | None) -> None:
|
||||
try:
|
||||
validate_provider_credential_id(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc
|
||||
|
||||
|
||||
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
|
||||
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
||||
return f"{id_line}event: {event}\ndata: {payload}\n\n"
|
||||
@@ -194,14 +231,21 @@ async def list_notes(
|
||||
folder: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> NoteListResponse:
|
||||
items, total = note_service.list_notes(limit=limit, offset=offset, folder=folder, tag=tag)
|
||||
return NoteListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
|
||||
items, total = note_service.list_notes(
|
||||
limit=limit, offset=offset, folder=folder, tag=tag
|
||||
)
|
||||
return NoteListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notes", response_model=Note, tags=["Notes"])
|
||||
async def create_note(request: NoteCreateRequest) -> Note:
|
||||
return await note_service.create_note(
|
||||
title=request.title, markdown=request.markdown, folder=request.folder, tags=request.tags
|
||||
title=request.title,
|
||||
markdown=request.markdown,
|
||||
folder=request.folder,
|
||||
tags=request.tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,7 +253,9 @@ async def create_note(request: NoteCreateRequest) -> Note:
|
||||
async def get_note(note_id: str) -> Note:
|
||||
note = await note_service.get_note(note_id)
|
||||
if note is None:
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
|
||||
)
|
||||
return note
|
||||
|
||||
|
||||
@@ -223,7 +269,9 @@ async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
|
||||
@router.delete("/notes/{note_id}", response_model=OperationResponse, tags=["Notes"])
|
||||
async def delete_note(note_id: str) -> OperationResponse:
|
||||
if not await note_service.delete_note(note_id):
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
|
||||
)
|
||||
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
|
||||
|
||||
|
||||
@@ -267,7 +315,9 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
||||
data={"code": "PROVIDER_ERROR", "message": str(exc)},
|
||||
timestamp=utc_now(),
|
||||
)
|
||||
done = ModelEvent(event=ModelEventType.done, sequence=1, timestamp=utc_now())
|
||||
done = ModelEvent(
|
||||
event=ModelEventType.done, sequence=1, timestamp=utc_now()
|
||||
)
|
||||
yield as_sse(error.event.value, error.model_dump_json())
|
||||
yield as_sse(done.event.value, done.model_dump_json())
|
||||
|
||||
@@ -428,9 +478,7 @@ async def list_skills() -> SkillListResponse:
|
||||
return SkillListResponse(items=container.skills.list())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_id}", response_model=Skill, tags=["Skills"]
|
||||
)
|
||||
@router.get("/skills/{skill_id}", response_model=Skill, tags=["Skills"])
|
||||
async def get_skill(skill_id: str) -> Skill:
|
||||
return extension_call(lambda: container.skills.get(skill_id))
|
||||
|
||||
@@ -470,7 +518,120 @@ async def disable_skill(skill_id: str) -> Skill:
|
||||
)
|
||||
async def uninstall_skill(skill_id: str) -> OperationResponse:
|
||||
extension_call(lambda: container.skills.uninstall(skill_id))
|
||||
return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled")
|
||||
return OperationResponse(
|
||||
status="completed", resource_id=skill_id, message="uninstalled"
|
||||
)
|
||||
|
||||
|
||||
# Independent MCP Server Registry
|
||||
@router.get("/mcp/servers", response_model=McpServerListResponse, tags=["MCP Servers"])
|
||||
async def list_mcp_servers() -> McpServerListResponse:
|
||||
return McpServerListResponse(items=await mcp_call_async(container.mcp_servers.list))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"]
|
||||
)
|
||||
async def create_mcp_server(request: McpServerCreateRequest) -> McpServer:
|
||||
return await mcp_call_async(lambda: container.mcp_servers.create(request))
|
||||
|
||||
|
||||
@router.get("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
|
||||
async def get_mcp_server(server_id: str) -> McpServer:
|
||||
return await mcp_call_async(lambda: container.mcp_servers.get(server_id))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mcp/servers/{server_id}/tools",
|
||||
response_model=McpToolSummaryListResponse,
|
||||
tags=["MCP Servers"],
|
||||
)
|
||||
async def list_mcp_server_tools(server_id: str) -> McpToolSummaryListResponse:
|
||||
return McpToolSummaryListResponse(
|
||||
items=await mcp_call_async(lambda: container.mcp_servers.list_tools(server_id))
|
||||
)
|
||||
|
||||
|
||||
@router.put("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
|
||||
async def update_mcp_server(
|
||||
server_id: str, request: McpServerUpdateRequest
|
||||
) -> McpServer:
|
||||
return await mcp_call_async(
|
||||
lambda: container.mcp_servers.update(server_id, request)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mcp/servers/{server_id}", response_model=OperationResponse, tags=["MCP Servers"]
|
||||
)
|
||||
async def delete_mcp_server(server_id: str) -> OperationResponse:
|
||||
await mcp_call_async(lambda: container.mcp_servers.delete(server_id))
|
||||
return OperationResponse(
|
||||
status="completed", resource_id=server_id, message="deleted"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"]
|
||||
)
|
||||
async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer:
|
||||
return await mcp_call_async(
|
||||
lambda: container.mcp_servers.trust(server_id, request.command_digest)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/servers/{server_id}/test", response_model=McpServer, tags=["MCP Servers"]
|
||||
)
|
||||
async def test_mcp_server(server_id: str) -> McpServer:
|
||||
return await mcp_call_async(lambda: container.mcp_servers.test(server_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/servers/{server_id}/enable", response_model=McpServer, tags=["MCP Servers"]
|
||||
)
|
||||
async def enable_mcp_server(server_id: str) -> McpServer:
|
||||
return await mcp_call_async(lambda: container.mcp_servers.enable(server_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mcp/servers/{server_id}/disable", response_model=McpServer, tags=["MCP Servers"]
|
||||
)
|
||||
async def disable_mcp_server(server_id: str) -> McpServer:
|
||||
return await mcp_call_async(lambda: container.mcp_servers.disable(server_id))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/mcp/servers/{server_id}/secrets/{key}",
|
||||
response_model=McpServerSecretStatus,
|
||||
tags=["MCP Servers"],
|
||||
)
|
||||
async def put_mcp_server_secret(
|
||||
server_id: str,
|
||||
key: str,
|
||||
request: McpServerSecretWriteRequest,
|
||||
kind: str = Query(default="environment", pattern="^(environment|header)$"),
|
||||
) -> McpServerSecretStatus:
|
||||
return await mcp_call_async(
|
||||
lambda: container.mcp_servers.put_secret(
|
||||
server_id, key, request.secret.get_secret_value(), kind=kind
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mcp/servers/{server_id}/secrets/{key}",
|
||||
response_model=McpServerSecretStatus,
|
||||
tags=["MCP Servers"],
|
||||
)
|
||||
async def delete_mcp_server_secret(
|
||||
server_id: str,
|
||||
key: str,
|
||||
kind: str = Query(default="environment", pattern="^(environment|header)$"),
|
||||
) -> McpServerSecretStatus:
|
||||
return await mcp_call_async(
|
||||
lambda: container.mcp_servers.delete_secret(server_id, key, kind=kind)
|
||||
)
|
||||
|
||||
|
||||
# Plugins
|
||||
@@ -562,11 +723,93 @@ async def restart_plugin_host(plugin_id: str) -> OperationResponse:
|
||||
)
|
||||
async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||
plugin = extension_call(lambda: container.plugins.get(plugin_id))
|
||||
dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools)
|
||||
dependent_skills = container.skills.depending_on_tools(
|
||||
plugin.manifest.contributes.tools
|
||||
)
|
||||
await extension_call_async(
|
||||
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
|
||||
)
|
||||
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
|
||||
return OperationResponse(
|
||||
status="completed", resource_id=plugin_id, message="uninstalled"
|
||||
)
|
||||
|
||||
|
||||
# Plugin Command / Settings Contributions
|
||||
@router.get(
|
||||
"/plugin-contributions/commands",
|
||||
response_model=PluginCommandListResponse,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def list_plugin_commands(
|
||||
location: PluginCommandLocation | None = Query(default=None),
|
||||
) -> PluginCommandListResponse:
|
||||
return PluginCommandListResponse(items=container.plugins.list_commands(location))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plugin-contributions/commands/{command_id}/execute",
|
||||
response_model=PluginCommandResult,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def execute_plugin_command(
|
||||
command_id: str, request: PluginCommandExecuteRequest
|
||||
) -> PluginCommandResult:
|
||||
try:
|
||||
return await container.plugins.execute_command(
|
||||
command_id, request.arguments, request.context
|
||||
)
|
||||
except ExtensionError as exc:
|
||||
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plugins/{plugin_id}/settings",
|
||||
response_model=PluginSettingsSchema,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def get_plugin_settings(plugin_id: str) -> PluginSettingsSchema:
|
||||
return extension_call(lambda: container.plugins.get_settings(plugin_id))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/plugins/{plugin_id}/settings",
|
||||
response_model=PluginSettingsSchema,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def update_plugin_settings(
|
||||
plugin_id: str, request: PluginSettingsUpdateRequest
|
||||
) -> PluginSettingsSchema:
|
||||
return extension_call(
|
||||
lambda: container.plugins.update_settings(
|
||||
plugin_id, request.schema_version, request.values
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/plugins/{plugin_id}/settings/{key}/secret",
|
||||
response_model=PluginSecretStatus,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def put_plugin_setting_secret(
|
||||
plugin_id: str, key: str, request: PluginSecretWriteRequest
|
||||
) -> PluginSecretStatus:
|
||||
return extension_call(
|
||||
lambda: container.plugins.put_setting_secret(
|
||||
plugin_id, key, request.secret.get_secret_value()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/plugins/{plugin_id}/settings/{key}/secret",
|
||||
response_model=PluginSecretStatus,
|
||||
tags=["Plugins"],
|
||||
)
|
||||
async def delete_plugin_setting_secret(plugin_id: str, key: str) -> PluginSecretStatus:
|
||||
return extension_call(
|
||||
lambda: container.plugins.delete_setting_secret(plugin_id, key)
|
||||
)
|
||||
|
||||
|
||||
# Providers
|
||||
@@ -576,6 +819,7 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
configured = container.credentials.has(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
@@ -591,6 +835,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
async def put_credential(
|
||||
credential_id: str, request: CredentialWriteRequest
|
||||
) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
container.credentials.put(credential_id, request.api_key.get_secret_value())
|
||||
except CredentialStoreError as exc:
|
||||
@@ -604,6 +849,7 @@ async def put_credential(
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def delete_credential(credential_id: str) -> CredentialStatus:
|
||||
validate_public_credential_id(credential_id)
|
||||
try:
|
||||
container.credentials.delete(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
@@ -640,6 +886,7 @@ async def get_provider(provider_id: str) -> ProviderConfig:
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
|
||||
validate_public_credential_id(request.credential_id)
|
||||
config = ProviderConfig(
|
||||
provider_id=f"provider_{uuid4().hex}",
|
||||
provider_type=request.provider_type,
|
||||
@@ -672,7 +919,9 @@ async def update_provider(
|
||||
) -> ProviderConfig:
|
||||
current = configurable_provider_or_404(provider_id).config
|
||||
if provider_id == "mock":
|
||||
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
|
||||
raise ApiError(
|
||||
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
|
||||
)
|
||||
fields = request.model_fields_set
|
||||
if ("name" in fields and request.name is None) or (
|
||||
"enabled" in fields and request.enabled is None
|
||||
@@ -683,6 +932,8 @@ async def update_provider(
|
||||
"name and enabled cannot be null when explicitly provided.",
|
||||
)
|
||||
updates = {name: getattr(request, name) for name in fields}
|
||||
if "credential_id" in fields:
|
||||
validate_public_credential_id(request.credential_id)
|
||||
config = ProviderConfig.model_validate(
|
||||
{**current.model_dump(mode="python"), **updates}
|
||||
)
|
||||
@@ -699,7 +950,9 @@ async def update_provider(
|
||||
async def delete_provider(provider_id: str) -> OperationResponse:
|
||||
configurable_provider_or_404(provider_id)
|
||||
if provider_id == "mock":
|
||||
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted.")
|
||||
raise ApiError(
|
||||
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted."
|
||||
)
|
||||
container.providers.unregister(provider_id)
|
||||
return OperationResponse(status="completed", resource_id=provider_id)
|
||||
|
||||
@@ -742,6 +995,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
||||
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
|
||||
registered = configurable_provider_or_404(request.provider_id)
|
||||
if request.credential_context_id:
|
||||
validate_public_credential_id(request.credential_context_id)
|
||||
temporary_config = registered.config.model_copy(
|
||||
update={"credential_id": request.credential_context_id, "enabled": True}
|
||||
)
|
||||
@@ -776,7 +1030,9 @@ async def create_task(request: TaskCreateRequest) -> Task:
|
||||
async def get_task(task_id: str) -> Task:
|
||||
task = task_service.get_task(task_id)
|
||||
if task is None:
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
@@ -792,7 +1048,9 @@ async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
|
||||
)
|
||||
async def delete_task(task_id: str) -> OperationResponse:
|
||||
if not task_service.delete_task(task_id):
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||
)
|
||||
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
|
||||
|
||||
|
||||
@@ -842,7 +1100,9 @@ async def rebuild_index(request: IndexRebuildRequest) -> IndexJob:
|
||||
async def get_index_job(job_id: str) -> IndexJob:
|
||||
job = index_service.get_job(job_id)
|
||||
if job is None:
|
||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id})
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id}
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""共享 JSON Schema 安全约束。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from referencing import Registry
|
||||
from referencing.exceptions import Unresolvable
|
||||
from referencing.jsonschema import DRAFT202012
|
||||
|
||||
_SCHEMA_BASE_URI = "https://notesagent.invalid/local-schema"
|
||||
|
||||
|
||||
class SchemaReferenceError(ValueError):
|
||||
"""Schema 引用不符合宿主的离线、文档内解析约束。"""
|
||||
|
||||
|
||||
class ExternalSchemaReferenceError(SchemaReferenceError):
|
||||
def __init__(self, keyword: str, reference: Any) -> None:
|
||||
super().__init__(f"External JSON Schema reference is not allowed: {reference!r}")
|
||||
self.keyword = keyword
|
||||
self.reference = reference
|
||||
|
||||
|
||||
class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
|
||||
def __init__(self, reference: str) -> None:
|
||||
super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}")
|
||||
self.reference = reference
|
||||
|
||||
|
||||
def reject_external_schema_references(schema: Any) -> None:
|
||||
"""只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析。"""
|
||||
|
||||
root = DRAFT202012.create_resource(schema)
|
||||
root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "")
|
||||
registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl()
|
||||
resolver = registry.resolver(root_uri)
|
||||
_validate_resource_references(root, resolver)
|
||||
|
||||
|
||||
def _validate_resource_references(resource, resolver: Any) -> None:
|
||||
contents = resource.contents
|
||||
if isinstance(contents, dict):
|
||||
for keyword in ("$ref", "$dynamicRef"):
|
||||
if keyword not in contents:
|
||||
continue
|
||||
reference = contents[keyword]
|
||||
if not isinstance(reference, str) or not reference.startswith("#"):
|
||||
raise ExternalSchemaReferenceError(keyword, reference)
|
||||
try:
|
||||
resolver.lookup(reference)
|
||||
except Unresolvable as exc:
|
||||
raise UnresolvableLocalSchemaReferenceError(reference) from exc
|
||||
|
||||
for subresource in resource.subresources():
|
||||
_validate_resource_references(
|
||||
subresource,
|
||||
resolver.in_subresource(subresource),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
commands:
|
||||
- command_id: mcp-fixture.notify
|
||||
title: MCP 通知
|
||||
description: 通过隔离 MCP Host 返回宿主白名单通知 effect。
|
||||
icon: bolt
|
||||
locations:
|
||||
- command_palette
|
||||
when:
|
||||
- editor.has_selection
|
||||
context:
|
||||
- selection
|
||||
secrets:
|
||||
- api_key
|
||||
mcp_tool: mcp-fixture.command
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
@@ -1,9 +1,10 @@
|
||||
id: mcp-fixture
|
||||
name: MCP Fixture
|
||||
version: 1.0.0
|
||||
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。
|
||||
description: 阶段 C/D 离线联调 Fixture,覆盖 MCP Tool、Command 与错误边界。
|
||||
permissions:
|
||||
- notes.read
|
||||
- secrets.use
|
||||
contributes:
|
||||
tools:
|
||||
- mcp-fixture.echo
|
||||
@@ -12,6 +13,10 @@ contributes:
|
||||
- mcp-fixture.large
|
||||
- mcp-fixture.environment
|
||||
- mcp-fixture.exit
|
||||
commands:
|
||||
- mcp-fixture.notify
|
||||
settings_sections:
|
||||
- mcp-fixture.general
|
||||
backend:
|
||||
type: mcp
|
||||
transport: stdio
|
||||
|
||||
@@ -54,6 +54,11 @@ TOOLS = {
|
||||
"large": tool("large", "Return a result larger than the host limit."),
|
||||
"environment": tool("environment", "Report whether host secrets leaked into the process."),
|
||||
"exit": tool("exit", "Terminate the fixture process."),
|
||||
"command": tool(
|
||||
"command",
|
||||
"Execute a NotesAgent Plugin Command envelope.",
|
||||
{"_notesagent": {"type": "object"}},
|
||||
),
|
||||
}
|
||||
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
|
||||
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
|
||||
@@ -62,6 +67,38 @@ TOOLS["echo"]["inputSchema"]["required"] = ["text"]
|
||||
def call_tool(request_id: int, params: dict[str, Any]) -> None:
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
if name == "command":
|
||||
envelope = arguments.get("_notesagent") or {}
|
||||
command_arguments = envelope.get("arguments") or {}
|
||||
context = envelope.get("context") or {}
|
||||
settings = envelope.get("settings") or {}
|
||||
secrets = envelope.get("secrets") or {}
|
||||
if not isinstance(secrets.get("api_key"), str):
|
||||
respond(
|
||||
request_id,
|
||||
{
|
||||
"content": [{"type": "text", "text": "declared secret missing"}],
|
||||
"isError": True,
|
||||
},
|
||||
)
|
||||
return
|
||||
message = command_arguments.get("message") or context.get("selection") or ""
|
||||
message = f"{settings.get('message_prefix', '')}{message}"
|
||||
respond(
|
||||
request_id,
|
||||
{
|
||||
"content": [{"type": "text", "text": "command completed"}],
|
||||
"structuredContent": {
|
||||
"type": "notification",
|
||||
"payload": {
|
||||
"level": "success",
|
||||
"message": str(message),
|
||||
},
|
||||
},
|
||||
"isError": False,
|
||||
},
|
||||
)
|
||||
return
|
||||
if name == "echo":
|
||||
text = str(arguments.get("text", ""))
|
||||
structured_content = {"echo": text}
|
||||
@@ -183,7 +220,14 @@ def main() -> None:
|
||||
elif params.get("cursor") == "page-2":
|
||||
respond(
|
||||
request_id,
|
||||
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
|
||||
{
|
||||
"tools": [
|
||||
TOOLS["large"],
|
||||
TOOLS["environment"],
|
||||
TOOLS["exit"],
|
||||
TOOLS["command"],
|
||||
]
|
||||
},
|
||||
)
|
||||
else:
|
||||
respond(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
section_id: mcp-fixture.general
|
||||
schema_version: 1
|
||||
fields:
|
||||
- key: message_prefix
|
||||
label: Message Prefix
|
||||
type: string
|
||||
default: ""
|
||||
- key: api_key
|
||||
label: Fixture API Key
|
||||
type: secret
|
||||
required: true
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"referencing>=0.36,<1.0",
|
||||
"sqlite-vec>=0.1.9",
|
||||
"uvicorn[standard]>=0.35,<1.0",
|
||||
]
|
||||
|
||||
+250
-23
@@ -1,26 +1,12 @@
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import health, service_status
|
||||
from app.routes import (
|
||||
get_index_status,
|
||||
list_notes,
|
||||
list_plugins,
|
||||
list_provider_presets,
|
||||
list_providers,
|
||||
list_skills,
|
||||
)
|
||||
from app.routes import (
|
||||
create_provider,
|
||||
create_task,
|
||||
delete_provider,
|
||||
delete_task,
|
||||
get_provider,
|
||||
get_task,
|
||||
list_tasks,
|
||||
update_provider,
|
||||
update_task,
|
||||
)
|
||||
from app.contracts import (
|
||||
McpServerSecretStatus,
|
||||
McpServerSecretWriteRequest,
|
||||
ProviderCreateRequest,
|
||||
ProviderType,
|
||||
ProviderUpdateRequest,
|
||||
@@ -28,6 +14,61 @@ from app.contracts import (
|
||||
TaskStatus,
|
||||
TaskUpdateRequest,
|
||||
)
|
||||
from app.main import health, service_status
|
||||
from app.routes import (
|
||||
create_provider,
|
||||
create_task,
|
||||
delete_provider,
|
||||
delete_task,
|
||||
get_index_status,
|
||||
get_provider,
|
||||
get_task,
|
||||
list_notes,
|
||||
list_plugins,
|
||||
list_provider_presets,
|
||||
list_providers,
|
||||
list_skills,
|
||||
list_tasks,
|
||||
update_provider,
|
||||
update_task,
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_secret_routes_offload_blocking_lifecycle_work(monkeypatch) -> None:
|
||||
from app import routes
|
||||
|
||||
caller_thread = threading.get_ident()
|
||||
worker_threads: list[int] = []
|
||||
|
||||
class FakeMcpRegistry:
|
||||
def put_secret(self, server_id, key, secret, *, kind):
|
||||
worker_threads.append(threading.get_ident())
|
||||
return McpServerSecretStatus(key=key, configured=True)
|
||||
|
||||
def delete_secret(self, server_id, key, *, kind):
|
||||
worker_threads.append(threading.get_ident())
|
||||
return McpServerSecretStatus(key=key, configured=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"container",
|
||||
SimpleNamespace(mcp_servers=FakeMcpRegistry()),
|
||||
)
|
||||
written = asyncio.run(
|
||||
routes.put_mcp_server_secret(
|
||||
"server-1",
|
||||
"TOKEN",
|
||||
McpServerSecretWriteRequest(secret="hidden"),
|
||||
kind="environment",
|
||||
)
|
||||
)
|
||||
deleted = asyncio.run(
|
||||
routes.delete_mcp_server_secret("server-1", "TOKEN", kind="environment")
|
||||
)
|
||||
|
||||
assert written.configured is True
|
||||
assert deleted.configured is False
|
||||
assert worker_threads and all(item != caller_thread for item in worker_threads)
|
||||
|
||||
|
||||
def test_health() -> None:
|
||||
@@ -36,6 +77,172 @@ def test_health() -> None:
|
||||
assert response.model_dump() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_mcp_create_and_trust_are_not_executed_on_event_loop(monkeypatch) -> None:
|
||||
from app import routes
|
||||
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
|
||||
|
||||
caller = threading.get_ident()
|
||||
workers = []
|
||||
|
||||
class Registry:
|
||||
def create(self, request):
|
||||
workers.append(threading.get_ident())
|
||||
return "created"
|
||||
|
||||
def trust(self, server_id, digest):
|
||||
workers.append(threading.get_ident())
|
||||
return "trusted"
|
||||
|
||||
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=Registry()))
|
||||
assert (
|
||||
asyncio.run(
|
||||
routes.create_mcp_server(McpServerCreateRequest(name="test", command="uvx"))
|
||||
)
|
||||
== "created"
|
||||
)
|
||||
assert (
|
||||
asyncio.run(
|
||||
routes.trust_mcp_server(
|
||||
"test", McpServerTrustRequest(command_digest="a" * 64)
|
||||
)
|
||||
)
|
||||
== "trusted"
|
||||
)
|
||||
assert len(workers) == 2
|
||||
assert all(worker != caller for worker in workers)
|
||||
|
||||
|
||||
def test_mcp_split_config_and_secret_requests_persist_without_plaintext(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import routes
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.config import get_settings
|
||||
from app.extensions.mcp_registry import McpServerRegistry
|
||||
from app.main import app
|
||||
from app.providers.credentials import EncryptedCredentialStore
|
||||
|
||||
service = McpServerRegistry(
|
||||
ToolRegistry(),
|
||||
EncryptedCredentialStore(),
|
||||
get_settings().data_dir,
|
||||
allow_process_launch=True,
|
||||
)
|
||||
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
|
||||
client = TestClient(app)
|
||||
config = {
|
||||
"name": "MiniMax configuration test",
|
||||
"command": "uvx",
|
||||
"environment": {"MINIMAX_API_HOST": "https://api.minimaxi.com"},
|
||||
"secret_environment_keys": ["MINIMAX_API_KEY"],
|
||||
"startup_timeout_seconds": 120,
|
||||
"tool_timeout_seconds": 300,
|
||||
}
|
||||
# Reproduce the old frontend payload. The backend still enforces separation.
|
||||
invalid = client.post(
|
||||
"/api/mcp/servers",
|
||||
json={
|
||||
**config,
|
||||
"environment": {
|
||||
**config["environment"],
|
||||
"MINIMAX_API_KEY": "synthetic-only",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
assert invalid.json()["error"]["code"] == "MCP_ENVIRONMENT_INVALID"
|
||||
created = client.post("/api/mcp/servers", json=config)
|
||||
assert created.status_code == 201
|
||||
server_id = created.json()["server_id"]
|
||||
saved = client.put(
|
||||
f"/api/mcp/servers/{server_id}/secrets/MINIMAX_API_KEY",
|
||||
json={"secret": "synthetic-only"},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
current = client.get(f"/api/mcp/servers/{server_id}")
|
||||
assert current.json()["secret_environment"] == {"MINIMAX_API_KEY": True}
|
||||
assert "synthetic-only" not in current.text
|
||||
assert "synthetic-only" not in service._path.read_text(encoding="utf-8")
|
||||
_, credentials_path = service.credentials._paths()
|
||||
assert "synthetic-only" not in credentials_path.read_text(encoding="utf-8")
|
||||
assert not current.json()["enabled"] # Saving never starts a third-party process.
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["create", "trust"])
|
||||
def test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive(
|
||||
monkeypatch,
|
||||
operation,
|
||||
) -> None:
|
||||
from app import routes
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.config import get_settings
|
||||
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
|
||||
from app.extensions.mcp_registry import McpServerRegistry
|
||||
from app.providers.credentials import EncryptedCredentialStore
|
||||
|
||||
service = McpServerRegistry(
|
||||
ToolRegistry(),
|
||||
EncryptedCredentialStore(),
|
||||
get_settings().data_dir,
|
||||
allow_process_launch=True,
|
||||
)
|
||||
request = McpServerCreateRequest(
|
||||
name="Lock contention fixture", command="not-executed"
|
||||
)
|
||||
server = service.create(request)
|
||||
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
|
||||
entered = threading.Event()
|
||||
locked = threading.Event()
|
||||
release = threading.Event()
|
||||
original = getattr(service, operation)
|
||||
|
||||
def observed(*args):
|
||||
entered.set()
|
||||
return original(*args)
|
||||
|
||||
def hold_lifecycle_lock():
|
||||
with service._lifecycle_lock:
|
||||
locked.set()
|
||||
release.wait(timeout=5)
|
||||
|
||||
monkeypatch.setattr(service, operation, observed)
|
||||
holder = threading.Thread(target=hold_lifecycle_lock, daemon=True)
|
||||
holder.start()
|
||||
# An independent watchdog lets the test fail rather than hang if a regression
|
||||
# blocks the event loop itself (an asyncio timeout alone cannot catch that).
|
||||
watchdog = threading.Timer(5, release.set)
|
||||
watchdog.start()
|
||||
|
||||
async def exercise():
|
||||
pending = asyncio.create_task(
|
||||
routes.create_mcp_server(request)
|
||||
if operation == "create"
|
||||
else routes.trust_mcp_server(
|
||||
server.server_id,
|
||||
McpServerTrustRequest(command_digest=server.command_digest),
|
||||
)
|
||||
)
|
||||
try:
|
||||
assert await asyncio.to_thread(entered.wait, 2)
|
||||
assert not pending.done()
|
||||
assert not release.is_set()
|
||||
assert (await health()).status == "ok"
|
||||
finally:
|
||||
release.set()
|
||||
await pending
|
||||
|
||||
try:
|
||||
assert locked.wait(timeout=2)
|
||||
asyncio.run(exercise())
|
||||
finally:
|
||||
release.set()
|
||||
watchdog.cancel()
|
||||
holder.join(timeout=2)
|
||||
|
||||
|
||||
def test_service_status() -> None:
|
||||
response = asyncio.run(service_status())
|
||||
|
||||
@@ -52,7 +259,9 @@ def test_core_collections_are_typed() -> None:
|
||||
|
||||
assert notes.items == []
|
||||
assert notes.page.limit == 20
|
||||
assert [skill.manifest.skill_id for skill in skills.items] == ["knowledge-assistant"]
|
||||
assert [skill.manifest.skill_id for skill in skills.items] == [
|
||||
"knowledge-assistant"
|
||||
]
|
||||
assert skills.items[0].status == "ready"
|
||||
assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"]
|
||||
assert plugins.items[0].status == "ready"
|
||||
@@ -73,9 +282,15 @@ def test_provider_presets_include_openai_and_deepseek() -> None:
|
||||
def test_provider_presets_static_route_precedes_provider_id_route() -> None:
|
||||
from app.routes import router
|
||||
|
||||
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())]
|
||||
get_paths = [
|
||||
route.path
|
||||
for route in router.routes
|
||||
if "GET" in getattr(route, "methods", set())
|
||||
]
|
||||
|
||||
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
|
||||
assert get_paths.index("/api/providers/presets") < get_paths.index(
|
||||
"/api/providers/{provider_id}"
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
@@ -95,8 +310,20 @@ 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/mcp/servers",
|
||||
"/api/mcp/servers/{server_id}",
|
||||
"/api/mcp/servers/{server_id}/tools",
|
||||
"/api/mcp/servers/{server_id}/trust",
|
||||
"/api/mcp/servers/{server_id}/test",
|
||||
"/api/mcp/servers/{server_id}/enable",
|
||||
"/api/mcp/servers/{server_id}/disable",
|
||||
"/api/mcp/servers/{server_id}/secrets/{key}",
|
||||
"/api/providers/test",
|
||||
"/api/providers/presets",
|
||||
"/api/credentials/{credential_id}",
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import CredentialWriteRequest
|
||||
from app.errors import ApiError
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
CredentialStoreError,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
from app.providers.factory import ProviderFactory
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
from app.routes import get_credential_status, put_credential
|
||||
from app.routes import delete_credential, get_credential_status, put_credential
|
||||
|
||||
|
||||
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
|
||||
@@ -31,6 +36,33 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No
|
||||
assert store.resolve("deepseek") is None
|
||||
|
||||
|
||||
def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("plugin.first", "first")
|
||||
store.put("plugin.second", "second")
|
||||
store.put("openai", "keep")
|
||||
|
||||
removed = store.delete_many(["plugin.first", "plugin.second"])
|
||||
|
||||
assert removed == {"plugin.first", "plugin.second"}
|
||||
assert store.resolve("plugin.first") is None
|
||||
assert store.resolve("plugin.second") is None
|
||||
assert store.resolve("openai") == "keep"
|
||||
|
||||
|
||||
def test_credential_write_os_error_uses_stable_store_error(monkeypatch) -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("existing", "value")
|
||||
|
||||
def fail_replace(_path: Path, _target: Path) -> Path:
|
||||
raise OSError("injected replace failure")
|
||||
|
||||
monkeypatch.setattr(Path, "replace", fail_replace)
|
||||
|
||||
with pytest.raises(CredentialStoreError, match="cannot be written"):
|
||||
store.put("new", "value")
|
||||
|
||||
|
||||
def test_credential_api_never_returns_secret() -> None:
|
||||
written = asyncio.run(
|
||||
put_credential(
|
||||
@@ -72,3 +104,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch
|
||||
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
|
||||
|
||||
assert resolver.resolve("deepseek") == "saved-key"
|
||||
|
||||
|
||||
def test_public_credential_api_rejects_plugin_namespace() -> None:
|
||||
operations = [
|
||||
get_credential_status("plugin.text-tools.api_key"),
|
||||
put_credential(
|
||||
"plugin.text-tools.api_key",
|
||||
CredentialWriteRequest(api_key="must-not-write"),
|
||||
),
|
||||
delete_credential("plugin.text-tools.api_key"),
|
||||
]
|
||||
for operation in operations:
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(operation)
|
||||
assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED"
|
||||
|
||||
assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None
|
||||
|
||||
|
||||
def test_provider_resolver_cannot_read_plugin_secret() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("plugin.text-tools.api_key", "private-plugin-secret")
|
||||
resolver = ProviderFactory(store).credentials
|
||||
|
||||
with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"):
|
||||
resolver.resolve("plugin.text-tools.api_key")
|
||||
|
||||
@@ -11,12 +11,16 @@ from app.container import build_container
|
||||
from app.contracts import (
|
||||
AgentRunCreateRequest,
|
||||
AgentRunStatus,
|
||||
PluginCommandContext,
|
||||
SkillStatus,
|
||||
ToolCall,
|
||||
)
|
||||
from app.extensions import ExtensionError
|
||||
from app.extensions.mcp import McpStdioClient
|
||||
from app.extensions.runtime import _arguments_model_from_schema
|
||||
from app.extensions.runtime import (
|
||||
_arguments_model_from_schema,
|
||||
_validate_mcp_command_target_schema,
|
||||
)
|
||||
from app.services import note_service
|
||||
from app.config import BACKEND_DIR, get_settings
|
||||
|
||||
@@ -33,7 +37,7 @@ def mcp_container():
|
||||
container = build_container()
|
||||
installed = container.plugins.install(MCP_FIXTURE)
|
||||
assert installed.status == "permission_required"
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
|
||||
try:
|
||||
yield container
|
||||
finally:
|
||||
@@ -349,7 +353,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
|
||||
ToolExecutionContext(run_id="run_mcp_fixture"),
|
||||
)
|
||||
|
||||
assert status.tools_count == 6
|
||||
assert status.tools_count == 7
|
||||
assert status.protocol_version == "2025-11-25"
|
||||
assert status.server_name == "notesagent-mcp-fixture"
|
||||
assert definition.permission == "notes.read"
|
||||
@@ -396,6 +400,162 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_mcp_command_target_receives_scoped_context_and_declared_secret(
|
||||
mcp_container,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
mcp_container.plugins.enable("mcp-fixture")
|
||||
|
||||
assert not mcp_container.tools.contains("mcp-fixture.command")
|
||||
with pytest.raises(ExtensionError) as missing:
|
||||
await mcp_container.plugins.execute_command(
|
||||
"mcp-fixture.notify",
|
||||
{},
|
||||
PluginCommandContext(selection="来自选区"),
|
||||
)
|
||||
assert missing.value.code == "PLUGIN_SECRET_REQUIRED"
|
||||
|
||||
mcp_container.plugins.put_setting_secret(
|
||||
"mcp-fixture", "api_key", "mcp-command-secret"
|
||||
)
|
||||
mcp_container.plugins.update_settings(
|
||||
"mcp-fixture", 1, {"message_prefix": "Fixture: "}
|
||||
)
|
||||
result = await mcp_container.plugins.execute_command(
|
||||
"mcp-fixture.notify",
|
||||
{},
|
||||
PluginCommandContext(
|
||||
note_id="must-not-enter-envelope",
|
||||
selection="来自选区",
|
||||
),
|
||||
)
|
||||
|
||||
assert result.effect.type == "notification"
|
||||
assert result.effect.payload.model_dump() == {
|
||||
"level": "success",
|
||||
"message": "Fixture: 来自选区",
|
||||
}
|
||||
assert "mcp-command-secret" not in repr(
|
||||
mcp_container.plugins.commands.audit_events()
|
||||
)
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_mcp_command_target_rejects_incompatible_envelope_schema(tmp_path) -> None:
|
||||
package = tmp_path / "mcp-bad-command"
|
||||
shutil.copytree(MCP_FIXTURE, package)
|
||||
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
|
||||
path = package / filename
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8").replace(
|
||||
"mcp-fixture", "mcp-bad-command"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
server_path = package / "server.py"
|
||||
server_path.write_text(
|
||||
server_path.read_text(encoding="utf-8").replace(
|
||||
'{"_notesagent": {"type": "object"}}',
|
||||
'{"unexpected": {"type": "string"}}',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
container = build_container()
|
||||
container.plugins.install(package)
|
||||
container.plugins.set_permissions(
|
||||
"mcp-bad-command", ["notes.read", "secrets.use"]
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.enable("mcp-bad-command")
|
||||
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
|
||||
assert container.plugins.get("mcp-bad-command").status == "error"
|
||||
finally:
|
||||
container.plugins.shutdown()
|
||||
|
||||
|
||||
def test_mcp_command_target_enable_check_only_requires_protocol_marker() -> None:
|
||||
# `not`/`oneOf` 等完整语义由实际调用前的官方 Validator 处理;启用检查
|
||||
# 只确认不可被引用或组合隐藏的稳定宿主入口,避免维护不完整的求解器。
|
||||
_validate_mcp_command_target_schema(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_notesagent": {
|
||||
"type": "object",
|
||||
"not": {"type": "object"},
|
||||
}
|
||||
},
|
||||
},
|
||||
"marker.run",
|
||||
)
|
||||
|
||||
invalid_markers = [
|
||||
{
|
||||
"$defs": {"envelope": {"type": "object"}},
|
||||
"properties": {"_notesagent": {"$ref": "#/$defs/envelope"}},
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{"properties": {"_notesagent": {"type": "object"}}},
|
||||
]
|
||||
},
|
||||
]
|
||||
for schema in invalid_markers:
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
_validate_mcp_command_target_schema(schema, "marker.run")
|
||||
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
|
||||
|
||||
|
||||
def test_mcp_command_validates_actual_envelope_before_call(tmp_path) -> None:
|
||||
package = tmp_path / "mcp-runtime-schema"
|
||||
shutil.copytree(MCP_FIXTURE, package)
|
||||
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
|
||||
path = package / filename
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8").replace(
|
||||
"mcp-fixture", "mcp-runtime-schema"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
server_path = package / "server.py"
|
||||
server_path.write_text(
|
||||
server_path.read_text(encoding="utf-8").replace(
|
||||
'{"_notesagent": {"type": "object"}}',
|
||||
'{"_notesagent": {"type": "object", "properties": '
|
||||
'{"arguments": {"type": "object", "maxProperties": 0}, '
|
||||
'"context": {"type": "object", "properties": '
|
||||
'{"selection": {"type": "string"}}, "required": ["selection"]}}, '
|
||||
'"required": ["arguments", "context"]}}',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
container = build_container()
|
||||
container.plugins.install(package)
|
||||
container.plugins.set_permissions(
|
||||
"mcp-runtime-schema", ["notes.read", "secrets.use"]
|
||||
)
|
||||
try:
|
||||
# context.selection 是 Command 的 when/context 契约保证的真实字段;
|
||||
# 启用期结构检查不得因没有伪造该业务值而拒绝目标 Schema。
|
||||
container.plugins.enable("mcp-runtime-schema")
|
||||
container.plugins.put_setting_secret(
|
||||
"mcp-runtime-schema", "api_key", "configured"
|
||||
)
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
run(
|
||||
container.plugins.execute_command(
|
||||
"mcp-runtime-schema.notify",
|
||||
{"message": "must be rejected locally"},
|
||||
PluginCommandContext(selection="visible"),
|
||||
)
|
||||
)
|
||||
assert exc.value.code == "PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH"
|
||||
finally:
|
||||
container.plugins.shutdown()
|
||||
|
||||
|
||||
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
|
||||
async def scenario() -> None:
|
||||
mcp_container.plugins.enable("mcp-fixture")
|
||||
@@ -531,7 +691,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
|
||||
container = build_container()
|
||||
installed = container.plugins.install(MCP_FIXTURE)
|
||||
assert installed.status == "permission_required"
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
|
||||
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
|
||||
try:
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.enable("mcp-fixture")
|
||||
@@ -565,7 +725,7 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container)
|
||||
|
||||
restarted = mcp_container.plugins.restart_host("mcp-fixture")
|
||||
assert restarted.status == "ready"
|
||||
assert restarted.tools_count == 6
|
||||
assert restarted.tools_count == 7
|
||||
assert mcp_container.tools.contains("mcp-fixture.echo")
|
||||
|
||||
run(scenario())
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||
from app.config import BACKEND_DIR, get_settings
|
||||
from app.contracts import McpServerCreateRequest, McpServerUpdateRequest, ToolCall
|
||||
from app.extensions.mcp import McpLegacySseClient
|
||||
from app.extensions.mcp_registry import McpRegistryError, McpServerRegistry
|
||||
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
||||
|
||||
SERVER = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo" / "server.py"
|
||||
|
||||
|
||||
def request(**overrides) -> McpServerCreateRequest:
|
||||
values = {
|
||||
"name": "Echo MCP",
|
||||
"command": sys.executable,
|
||||
"args": [str(SERVER)],
|
||||
"permissions": ["notes.read", "secrets.use"],
|
||||
"secret_environment_keys": ["TEST_MCP_SECRET"],
|
||||
}
|
||||
values.update(overrides)
|
||||
return McpServerCreateRequest(**values)
|
||||
|
||||
|
||||
def registry(*, launch: bool = True) -> McpServerRegistry:
|
||||
return McpServerRegistry(
|
||||
ToolRegistry(),
|
||||
EncryptedCredentialStore(),
|
||||
get_settings().data_dir,
|
||||
allow_process_launch=launch,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_requires_current_trust_and_never_returns_secret() -> None:
|
||||
service = registry()
|
||||
created = service.create(request())
|
||||
assert created.trusted is False
|
||||
assert created.secret_environment == {"TEST_MCP_SECRET": False}
|
||||
|
||||
service.put_secret(created.server_id, "TEST_MCP_SECRET", "do-not-return")
|
||||
configured = service.get(created.server_id)
|
||||
assert configured.secret_environment == {"TEST_MCP_SECRET": True}
|
||||
assert "do-not-return" not in configured.model_dump_json()
|
||||
|
||||
with pytest.raises(McpRegistryError, match="approve"):
|
||||
service.test(created.server_id)
|
||||
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
tested = service.test(created.server_id)
|
||||
assert tested.status == "stopped"
|
||||
assert tested.last_test_succeeded is True
|
||||
assert tested.tools_count > 0
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_secret_change_disables_server_and_requires_a_new_connection_test() -> None:
|
||||
service = registry()
|
||||
created = service.create(request())
|
||||
service.put_secret(created.server_id, "TEST_MCP_SECRET", "first")
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
service.test(created.server_id)
|
||||
service.enable(created.server_id)
|
||||
|
||||
service.put_secret(created.server_id, "TEST_MCP_SECRET", "second")
|
||||
current = service.get(created.server_id)
|
||||
assert current.enabled is False
|
||||
assert current.last_test_succeeded is None
|
||||
assert not any(
|
||||
item.name.startswith(f"mcp.{created.server_id}.")
|
||||
for item in service.tools.definitions()
|
||||
)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.enable(created.server_id)
|
||||
assert error.value.code == "MCP_CONNECTION_TEST_REQUIRED"
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_update_disables_server_and_revokes_command_trust() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=[]))
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
service.test(created.server_id)
|
||||
enabled = service.enable(created.server_id)
|
||||
assert enabled.enabled is True
|
||||
assert any(
|
||||
item.name.startswith(f"mcp.{created.server_id}.")
|
||||
for item in service.tools.definitions()
|
||||
)
|
||||
|
||||
updated = service.update(
|
||||
created.server_id,
|
||||
McpServerUpdateRequest(
|
||||
**request(name="Changed", secret_environment_keys=[]).model_dump(),
|
||||
version=enabled.version,
|
||||
),
|
||||
)
|
||||
assert updated.enabled is False
|
||||
assert updated.trusted is False
|
||||
assert not any(
|
||||
item.name.startswith(f"mcp.{created.server_id}.")
|
||||
for item in service.tools.definitions()
|
||||
)
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_update_remains_retryable_when_removed_secret_cleanup_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
service = registry()
|
||||
created = service.create(request())
|
||||
service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry")
|
||||
|
||||
def fail_delete_many(_secret_ids: list[str]) -> set[str]:
|
||||
raise CredentialStoreError("credential store unavailable")
|
||||
|
||||
monkeypatch.setattr(service.credentials, "delete_many", fail_delete_many)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.update(
|
||||
created.server_id,
|
||||
McpServerUpdateRequest(
|
||||
**request(secret_environment_keys=[]).model_dump(),
|
||||
version=created.version,
|
||||
),
|
||||
)
|
||||
|
||||
current = service.get(created.server_id)
|
||||
assert error.value.code == "MCP_SECRET_STORE_ERROR"
|
||||
assert current.version == created.version
|
||||
assert current.secret_environment == {"TEST_MCP_SECRET": True}
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_delete_keeps_server_retryable_when_secret_cleanup_fails(monkeypatch) -> None:
|
||||
service = registry()
|
||||
created = service.create(request())
|
||||
service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry")
|
||||
|
||||
def fail_delete_many(_secret_ids: list[str]) -> set[str]:
|
||||
raise CredentialStoreError("credential store unavailable")
|
||||
|
||||
monkeypatch.setattr(service.credentials, "delete_many", fail_delete_many)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.delete(created.server_id)
|
||||
|
||||
current = service.get(created.server_id)
|
||||
assert error.value.code == "MCP_SECRET_STORE_ERROR"
|
||||
assert current.server_id == created.server_id
|
||||
assert current.secret_environment == {"TEST_MCP_SECRET": True}
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_unavailable_server_removes_bridge_host(monkeypatch) -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=[]))
|
||||
with service._lock:
|
||||
service._records[created.server_id] = {
|
||||
**service._records[created.server_id],
|
||||
"enabled": True,
|
||||
}
|
||||
removed: list[str] = []
|
||||
monkeypatch.setattr(service.bridge, "remove", removed.append)
|
||||
|
||||
generation = object()
|
||||
service._generations[created.server_id] = generation
|
||||
service._unavailable(created.server_id, generation, "connection lost")
|
||||
|
||||
current = service.get(created.server_id)
|
||||
assert removed == [f"mcp.{created.server_id}"]
|
||||
assert current.enabled is False
|
||||
assert current.status == "unhealthy"
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_old_failure_callback_cannot_stop_replacement_host(monkeypatch) -> None:
|
||||
service = registry()
|
||||
callbacks = []
|
||||
original_start = service.bridge.start
|
||||
|
||||
def capture_callback(*args, **kwargs):
|
||||
callbacks.append(args[4])
|
||||
return original_start(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(service.bridge, "start", capture_callback)
|
||||
created = service.create(request(secret_environment_keys=[]))
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
callback_thread = None
|
||||
try:
|
||||
service.test(created.server_id)
|
||||
service.enable(created.server_id)
|
||||
old_callback = callbacks[-1]
|
||||
callback_started = threading.Event()
|
||||
callback_finished = threading.Event()
|
||||
|
||||
def delayed_failure():
|
||||
callback_started.set()
|
||||
old_callback(f"mcp.{created.server_id}", "delayed old failure")
|
||||
callback_finished.set()
|
||||
|
||||
# Queue the old callback while a replacement owns the lifecycle lock.
|
||||
with service._lifecycle_lock:
|
||||
callback_thread = threading.Thread(target=delayed_failure, daemon=True)
|
||||
callback_thread.start()
|
||||
assert callback_started.wait(timeout=2)
|
||||
service.disable(created.server_id)
|
||||
service.enable(created.server_id)
|
||||
assert callback_finished.wait(timeout=2)
|
||||
assert service.get(created.server_id).enabled is True
|
||||
assert service.get(created.server_id).status == "ready"
|
||||
assert service.tools.definitions()
|
||||
callbacks[-1](f"mcp.{created.server_id}", "current failure")
|
||||
assert service.get(created.server_id).enabled is False
|
||||
assert service.get(created.server_id).status == "unhealthy"
|
||||
finally:
|
||||
service.shutdown()
|
||||
if callback_thread is not None:
|
||||
callback_thread.join(timeout=2)
|
||||
|
||||
|
||||
def test_header_case_only_rename_preserves_secret() -> None:
|
||||
service = registry()
|
||||
config = {
|
||||
"name": "HTTP",
|
||||
"transport": "streamable_http",
|
||||
"url": "https://example.test/mcp",
|
||||
"secret_header_keys": ["Authorization"],
|
||||
}
|
||||
created = service.create(McpServerCreateRequest(**config))
|
||||
service.put_secret(created.server_id, "Authorization", "synthetic", kind="header")
|
||||
config["secret_header_keys"] = ["authorization"]
|
||||
updated = service.update(
|
||||
created.server_id, McpServerUpdateRequest(**config, version=created.version)
|
||||
)
|
||||
assert updated.secret_headers == {"authorization": True}
|
||||
assert (
|
||||
service.credentials.resolve(
|
||||
service._secret_id(created.server_id, "authorization", "header")
|
||||
)
|
||||
== "synthetic"
|
||||
)
|
||||
|
||||
|
||||
def test_environment_secrets_are_case_sensitive_and_delete_independently() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
|
||||
service.put_secret(created.server_id, "TOKEN", "upper")
|
||||
service.put_secret(created.server_id, "token", "lower")
|
||||
assert (
|
||||
service.credentials.resolve(service._secret_id(created.server_id, "TOKEN"))
|
||||
== "upper"
|
||||
)
|
||||
assert (
|
||||
service.credentials.resolve(service._secret_id(created.server_id, "token"))
|
||||
== "lower"
|
||||
)
|
||||
service.delete_secret(created.server_id, "TOKEN")
|
||||
assert service.get(created.server_id).secret_environment == {
|
||||
"TOKEN": False,
|
||||
"token": True,
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_environment_credential_migration_is_idempotent() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=["TOKEN"]))
|
||||
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
|
||||
legacy_id = f"mcp.{created.server_id}.{suffix}"
|
||||
service.credentials.put(legacy_id, "legacy-value")
|
||||
service._records[created.server_id]["secret_environment_version"] = 1
|
||||
service._write()
|
||||
migrated = registry()
|
||||
assert migrated.get(created.server_id).secret_environment == {"TOKEN": True}
|
||||
assert (
|
||||
migrated.credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
|
||||
== "legacy-value"
|
||||
)
|
||||
assert not migrated.credentials.has(legacy_id)
|
||||
migrated.put_secret(created.server_id, "TOKEN", "new-value")
|
||||
assert (
|
||||
registry().credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
|
||||
== "new-value"
|
||||
)
|
||||
|
||||
|
||||
def test_ambiguous_legacy_credentials_are_not_assigned_to_two_variables() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
|
||||
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
|
||||
legacy_id = f"mcp.{created.server_id}.{suffix}"
|
||||
service.credentials.put(legacy_id, "cannot-reconstruct-originals")
|
||||
service._records[created.server_id]["secret_environment_version"] = 1
|
||||
service._write()
|
||||
migrated = registry()
|
||||
current = migrated.get(created.server_id)
|
||||
assert current.secret_environment == {"TOKEN": False, "token": False}
|
||||
assert current.enabled is False
|
||||
assert current.last_test_succeeded is None
|
||||
assert migrated.credentials.has(
|
||||
legacy_id
|
||||
) # Keep the original ciphertext recoverable.
|
||||
migrated.put_secret(created.server_id, "TOKEN", "upper")
|
||||
migrated.put_secret(created.server_id, "token", "lower")
|
||||
assert registry().get(created.server_id).secret_environment == {
|
||||
"TOKEN": True,
|
||||
"token": True,
|
||||
}
|
||||
migrated.delete(created.server_id)
|
||||
assert not migrated.credentials.has(legacy_id)
|
||||
|
||||
|
||||
def test_credential_id_migration_keeps_new_values_and_is_atomic(monkeypatch) -> None:
|
||||
credentials = EncryptedCredentialStore()
|
||||
credentials.put("mcp.old", "old-value")
|
||||
credentials.put("mcp.new", "new-value")
|
||||
original_write = credentials._write_tokens
|
||||
|
||||
def fail_write(_tokens):
|
||||
raise CredentialStoreError("synthetic failure")
|
||||
|
||||
monkeypatch.setattr(credentials, "_write_tokens", fail_write)
|
||||
with pytest.raises(CredentialStoreError):
|
||||
credentials.move_many({"mcp.old": "mcp.new"})
|
||||
assert credentials.resolve("mcp.old") == "old-value"
|
||||
assert credentials.resolve("mcp.new") == "new-value"
|
||||
monkeypatch.setattr(credentials, "_write_tokens", original_write)
|
||||
credentials.move_many({"mcp.old": "mcp.new"})
|
||||
assert credentials.resolve("mcp.old") is None
|
||||
assert credentials.resolve("mcp.new") == "new-value"
|
||||
|
||||
|
||||
def test_ambiguous_legacy_secret_is_not_resurrected_after_removing_a_key() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
|
||||
legacy_id = service._legacy_environment_secret_id(created.server_id, "TOKEN")
|
||||
service.credentials.put(legacy_id, "ambiguous-old-value")
|
||||
service._records[created.server_id]["secret_environment_version"] = 1
|
||||
service._write()
|
||||
migrated = registry()
|
||||
migrated.update(
|
||||
created.server_id,
|
||||
McpServerUpdateRequest(
|
||||
**request(secret_environment_keys=["token"]).model_dump(),
|
||||
version=created.version,
|
||||
),
|
||||
)
|
||||
assert registry().get(created.server_id).secret_environment == {"token": False}
|
||||
|
||||
|
||||
def test_production_rejects_process_launch_even_after_approval() -> None:
|
||||
service = registry(launch=False)
|
||||
created = service.create(request(secret_environment_keys=[]))
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.enable(created.server_id)
|
||||
assert error.value.code == "MCP_SANDBOX_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("startup,tool", [(120, 300), (1.5, 2.5)])
|
||||
def test_server_timeouts_survive_bridge_adaptation_and_reload(startup, tool) -> None:
|
||||
service = registry()
|
||||
created = service.create(
|
||||
request(
|
||||
secret_environment_keys=[],
|
||||
startup_timeout_seconds=startup,
|
||||
tool_timeout_seconds=tool,
|
||||
)
|
||||
)
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
try:
|
||||
tested = service.test(created.server_id)
|
||||
assert tested.last_test_succeeded is True
|
||||
assert tested.startup_timeout_seconds == startup
|
||||
assert tested.tool_timeout_seconds == tool
|
||||
restored = registry().get(created.server_id)
|
||||
assert restored.startup_timeout_seconds == startup
|
||||
assert restored.tool_timeout_seconds == tool
|
||||
finally:
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_enable_requires_successful_test_and_update_checks_version() -> None:
|
||||
service = registry()
|
||||
created = service.create(request(secret_environment_keys=[]))
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.enable(created.server_id)
|
||||
assert error.value.code == "MCP_CONNECTION_TEST_REQUIRED"
|
||||
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.update(
|
||||
created.server_id,
|
||||
McpServerUpdateRequest(
|
||||
**request(secret_environment_keys=[]).model_dump(), version=99
|
||||
),
|
||||
)
|
||||
assert error.value.code == "MCP_SERVER_VERSION_CONFLICT"
|
||||
|
||||
|
||||
def test_http_transport_rejects_invalid_cross_transport_fields() -> None:
|
||||
service = registry()
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.create(
|
||||
request(
|
||||
transport="streamable_http",
|
||||
url="https://example.invalid/mcp",
|
||||
secret_environment_keys=[],
|
||||
)
|
||||
)
|
||||
assert error.value.code == "MCP_CONFIG_INVALID"
|
||||
|
||||
|
||||
def test_registry_rejects_corrupt_persisted_json(tmp_path) -> None:
|
||||
path = tmp_path / "mcp"
|
||||
path.mkdir()
|
||||
(path / "servers.json").write_text("{broken", encoding="utf-8")
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
McpServerRegistry(
|
||||
ToolRegistry(),
|
||||
EncryptedCredentialStore(),
|
||||
tmp_path,
|
||||
allow_process_launch=True,
|
||||
)
|
||||
assert error.value.code == "MCP_REGISTRY_INVALID"
|
||||
|
||||
|
||||
def test_registry_rejects_structurally_invalid_record(tmp_path) -> None:
|
||||
path = tmp_path / "mcp"
|
||||
path.mkdir()
|
||||
(path / "servers.json").write_text(
|
||||
json.dumps({"server-1": {"name": "Broken", "transport": "stdio"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
McpServerRegistry(
|
||||
ToolRegistry(),
|
||||
EncryptedCredentialStore(),
|
||||
tmp_path,
|
||||
allow_process_launch=True,
|
||||
)
|
||||
assert error.value.code == "MCP_REGISTRY_INVALID"
|
||||
|
||||
|
||||
def test_registry_rejects_create_before_exceeding_persisted_limit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
service = registry()
|
||||
service.create(request(name="Only server"))
|
||||
monkeypatch.setattr("app.extensions.mcp_registry._MAX_MCP_SERVERS", 1)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.create(request(name="One too many"))
|
||||
assert error.value.code == "MCP_SERVER_LIMIT_REACHED"
|
||||
assert len(service.list()) == 1
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_stdio_command_is_not_parsed_as_a_shell_string() -> None:
|
||||
service = registry()
|
||||
created = service.create(
|
||||
request(
|
||||
command=f'"{sys.executable}" "{SERVER}"',
|
||||
args=[],
|
||||
secret_environment_keys=[],
|
||||
)
|
||||
)
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.test(created.server_id)
|
||||
assert error.value.code == "PLUGIN_HOST_START_FAILED"
|
||||
assert service.get(created.server_id).last_test_succeeded is False
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def test_enabled_server_is_restored_from_persisted_registry() -> None:
|
||||
first = registry()
|
||||
created = first.create(request(secret_environment_keys=[]))
|
||||
first.trust(created.server_id, created.command_digest)
|
||||
first.test(created.server_id)
|
||||
first.enable(created.server_id)
|
||||
first.shutdown()
|
||||
|
||||
restored = registry()
|
||||
restored.restore_enabled()
|
||||
current = restored.get(created.server_id)
|
||||
assert current.enabled is True
|
||||
assert current.status == "ready"
|
||||
assert any(
|
||||
item.name.startswith(f"mcp.{created.server_id}.")
|
||||
for item in restored.tools.definitions()
|
||||
)
|
||||
restored.shutdown()
|
||||
|
||||
|
||||
def test_lifecycle_operations_are_serialized_and_tool_names_are_isolated() -> None:
|
||||
service = registry()
|
||||
servers = [
|
||||
service.create(request(name=f"Echo {index}", secret_environment_keys=[]))
|
||||
for index in range(2)
|
||||
]
|
||||
for server in servers:
|
||||
service.trust(server.server_id, server.command_digest)
|
||||
service.test(server.server_id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
enabled = list(
|
||||
pool.map(lambda item: service.enable(item.server_id), servers * 2)
|
||||
)
|
||||
assert all(item.enabled for item in enabled)
|
||||
names = [
|
||||
item.name for item in service.tools.definitions() if item.source == "mcp_server"
|
||||
]
|
||||
assert len(names) == len(set(names))
|
||||
assert all(
|
||||
any(name.startswith(f"mcp.{item.server_id}.") for name in names)
|
||||
for item in servers
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
list(pool.map(lambda item: service.disable(item.server_id), servers * 2))
|
||||
assert not any(item.source == "mcp_server" for item in service.tools.definitions())
|
||||
service.shutdown()
|
||||
|
||||
|
||||
def _http_result(request_id: int, result: dict) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "application/json"},
|
||||
json={"jsonrpc": "2.0", "id": request_id, "result": result},
|
||||
)
|
||||
|
||||
|
||||
def test_streamable_http_supports_session_headers_secrets_and_tool_summary(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
request_timeouts: dict[str, float] = {}
|
||||
|
||||
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||
requests.append(request_value)
|
||||
if request_value.method == "GET":
|
||||
return httpx.Response(405)
|
||||
if request_value.method == "DELETE":
|
||||
return httpx.Response(405)
|
||||
payload = json.loads(request_value.content)
|
||||
timeout = request_value.extensions.get("timeout", {}).get("read")
|
||||
if isinstance(timeout, (int, float)):
|
||||
request_timeouts[payload.get("method", "notification")] = float(timeout)
|
||||
if payload.get("method") == "initialize":
|
||||
response = _http_result(
|
||||
payload["id"],
|
||||
{
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "HTTP Fixture", "version": "1"},
|
||||
},
|
||||
)
|
||||
response.headers["MCP-Session-Id"] = "session-test"
|
||||
return response
|
||||
if payload.get("method") == "tools/list":
|
||||
return _http_result(
|
||||
payload["id"],
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Echo over HTTP",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
if payload.get("method") == "tools/call":
|
||||
return _http_result(
|
||||
payload["id"], {"structuredContent": {"transport": "http"}}
|
||||
)
|
||||
return httpx.Response(202)
|
||||
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(
|
||||
"app.extensions.mcp.httpx.Client",
|
||||
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
service = registry()
|
||||
created = service.create(
|
||||
McpServerCreateRequest(
|
||||
name="Remote MCP",
|
||||
transport="streamable_http",
|
||||
url="https://mcp.example.test/mcp",
|
||||
headers={"X-Client": "NotesAgent"},
|
||||
secret_header_keys=["Authorization"],
|
||||
)
|
||||
)
|
||||
service.put_secret(
|
||||
created.server_id, "Authorization", "Bearer hidden", kind="header"
|
||||
)
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
tested = service.test(created.server_id)
|
||||
|
||||
assert tested.last_test_succeeded is True
|
||||
assert tested.secret_headers == {"Authorization": True}
|
||||
assert "Bearer hidden" not in tested.model_dump_json()
|
||||
assert service.list_tools(created.server_id)[0].remote_name == "echo"
|
||||
assert any(
|
||||
request.headers.get("mcp-session-id") == "session-test" for request in requests
|
||||
)
|
||||
assert any(
|
||||
request.headers.get("mcp-protocol-version") == "2025-11-25"
|
||||
for request in requests
|
||||
)
|
||||
assert all(
|
||||
request.headers.get("authorization") == "Bearer hidden" for request in requests
|
||||
)
|
||||
assert request_timeouts["initialize"] == 15
|
||||
assert request_timeouts["notifications/initialized"] == 15
|
||||
assert request_timeouts["tools/list"] == 15
|
||||
enabled = service.enable(created.server_id)
|
||||
tool_name = service.list_tools(created.server_id)[0].name
|
||||
result = asyncio.run(
|
||||
service.tools.execute(
|
||||
ToolCall(tool_call_id="call-1", name=tool_name, arguments={}),
|
||||
ToolExecutionContext(run_id="run-1"),
|
||||
)
|
||||
)
|
||||
assert enabled.enabled is True
|
||||
assert result.success is True
|
||||
assert result.output == {"transport": "http"}
|
||||
assert request_timeouts["tools/call"] == 30
|
||||
service.disable(created.server_id)
|
||||
service.shutdown()
|
||||
|
||||
|
||||
class _LegacyEventStream(httpx.SyncByteStream):
|
||||
def __init__(self) -> None:
|
||||
self.closed = threading.Event()
|
||||
|
||||
def __iter__(self):
|
||||
yield b"event: endpoint\ndata: /messages\n\n"
|
||||
time.sleep(0.1)
|
||||
initialize = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "Legacy Fixture"},
|
||||
},
|
||||
}
|
||||
yield f"data: {json.dumps(initialize)}\n\n".encode()
|
||||
time.sleep(0.1)
|
||||
tools = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {"tools": []},
|
||||
}
|
||||
yield f"data: {json.dumps(tools)}\n\n".encode()
|
||||
self.closed.wait()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed.set()
|
||||
|
||||
|
||||
def test_legacy_sse_uses_same_origin_endpoint(monkeypatch) -> None:
|
||||
posted_urls: list[str] = []
|
||||
event_stream = _LegacyEventStream()
|
||||
|
||||
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||
if request_value.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=event_stream,
|
||||
)
|
||||
posted_urls.append(str(request_value.url))
|
||||
return httpx.Response(202)
|
||||
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(
|
||||
"app.extensions.mcp.httpx.Client",
|
||||
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
service = registry()
|
||||
created = service.create(
|
||||
McpServerCreateRequest(
|
||||
name="Legacy MCP",
|
||||
transport="sse",
|
||||
url="https://legacy.example.test/sse",
|
||||
)
|
||||
)
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
tested = service.test(created.server_id)
|
||||
assert tested.last_test_succeeded is True
|
||||
assert posted_urls and all(
|
||||
url == "https://legacy.example.test/messages" for url in posted_urls
|
||||
)
|
||||
service.shutdown()
|
||||
event_stream.close()
|
||||
|
||||
|
||||
class _EndingLegacyEventStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"event: endpoint\ndata: /messages\n\n"
|
||||
|
||||
|
||||
def test_legacy_sse_eof_marks_client_unavailable(monkeypatch) -> None:
|
||||
def handler(_request_value: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_EndingLegacyEventStream(),
|
||||
)
|
||||
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(
|
||||
"app.extensions.mcp.httpx.Client",
|
||||
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
broken = threading.Event()
|
||||
client = McpLegacySseClient(
|
||||
"https://legacy.example.test/sse",
|
||||
headers={},
|
||||
startup_timeout_seconds=1,
|
||||
on_seen=lambda: None,
|
||||
on_broken=lambda _message: broken.set(),
|
||||
on_tools_changed=lambda: None,
|
||||
)
|
||||
client.start()
|
||||
assert broken.wait(timeout=1)
|
||||
client.stop()
|
||||
|
||||
|
||||
class _CrossOriginLegacyEventStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"event: endpoint\ndata: https://attacker.example/messages\n\n"
|
||||
|
||||
|
||||
def test_legacy_sse_rejects_cross_origin_message_endpoint(monkeypatch) -> None:
|
||||
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||
assert request_value.method == "GET"
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_CrossOriginLegacyEventStream(),
|
||||
)
|
||||
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(
|
||||
"app.extensions.mcp.httpx.Client",
|
||||
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
service = registry()
|
||||
created = service.create(
|
||||
McpServerCreateRequest(
|
||||
name="Unsafe legacy MCP",
|
||||
transport="sse",
|
||||
url="https://legacy.example.test/sse",
|
||||
)
|
||||
)
|
||||
service.trust(created.server_id, created.command_digest)
|
||||
with pytest.raises(McpRegistryError) as error:
|
||||
service.test(created.server_id)
|
||||
assert error.value.code == "MCP_HTTP_RESPONSE_INVALID"
|
||||
service.shutdown()
|
||||
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
from contextlib import closing
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.extensions import mcp
|
||||
|
||||
|
||||
class ChunkStream(httpx.SyncByteStream):
|
||||
def __init__(self, chunks):
|
||||
self.chunks = chunks
|
||||
self.bytes_read = 0
|
||||
|
||||
def __iter__(self):
|
||||
for chunk in self.chunks:
|
||||
self.bytes_read += len(chunk)
|
||||
yield chunk
|
||||
|
||||
|
||||
def test_sse_rejects_unterminated_line_before_reading_entire_stream(monkeypatch):
|
||||
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 1024)
|
||||
stream = ChunkStream([b"x" * 256] * 256)
|
||||
with (
|
||||
closing(httpx.Response(200, stream=stream)) as response,
|
||||
pytest.raises(mcp.McpBridgeError, match="too large"),
|
||||
):
|
||||
list(mcp._iter_sse(response))
|
||||
assert stream.bytes_read == 1280
|
||||
|
||||
|
||||
def test_sse_limits_combined_event_before_partial_line_is_complete(monkeypatch):
|
||||
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 32)
|
||||
stream = ChunkStream(
|
||||
[b"data: 123456789\n", b"data: 123456789\n", b"x", b"not-read"]
|
||||
)
|
||||
with (
|
||||
closing(httpx.Response(200, stream=stream)) as response,
|
||||
pytest.raises(mcp.McpBridgeError, match="too large"),
|
||||
):
|
||||
list(mcp._iter_sse(response))
|
||||
assert stream.bytes_read == 33
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", [b"\n", b"\r", b"\r\n"])
|
||||
@pytest.mark.parametrize("chunk_size", [1, 2, 7, 1024])
|
||||
def test_sse_preserves_utf8_and_line_endings_across_chunks(separator, chunk_size):
|
||||
payload = json.dumps(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {"text": "中文"}}, ensure_ascii=False
|
||||
)
|
||||
wire = b"\xef\xbb\xbf" + separator.join(
|
||||
[
|
||||
b": heartbeat",
|
||||
b"event: message",
|
||||
b"id: replay-1",
|
||||
("data: " + payload).encode(),
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
)
|
||||
stream = ChunkStream(
|
||||
[wire[index : index + chunk_size] for index in range(0, len(wire), chunk_size)]
|
||||
)
|
||||
with closing(httpx.Response(200, stream=stream)) as response:
|
||||
assert list(mcp._iter_sse(response)) == [("message", "replay-1", payload)]
|
||||
|
||||
|
||||
def test_sse_event_limit_resets_between_events(monkeypatch):
|
||||
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 16)
|
||||
with closing(
|
||||
httpx.Response(200, stream=ChunkStream([b"data: one\n\ndata: two\r\r"]))
|
||||
) as response:
|
||||
assert list(mcp._iter_sse(response)) == [
|
||||
("message", None, "one"),
|
||||
("message", None, "two"),
|
||||
]
|
||||
|
||||
|
||||
def test_sse_preserves_multiline_data_and_final_unterminated_line():
|
||||
with closing(
|
||||
httpx.Response(200, stream=ChunkStream([b"data: first\ndata: last"]))
|
||||
) as response:
|
||||
assert list(mcp._iter_sse(response)) == [("message", None, "first\nlast")]
|
||||
@@ -0,0 +1,653 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.agent import ToolRegistry
|
||||
from app.config import BACKEND_DIR, get_settings
|
||||
from app.container import build_container
|
||||
from app.contracts import (
|
||||
PluginCommandContext,
|
||||
PluginCommandEffect,
|
||||
PluginNoEffect,
|
||||
PluginSettingType,
|
||||
)
|
||||
from app.extensions import ExtensionError, PluginRuntime
|
||||
from app.extensions.contributions import _secret_reference
|
||||
from app.extensions.runtime import DeclarativePluginHost
|
||||
from app.providers.credentials import CredentialStoreError
|
||||
|
||||
TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools"
|
||||
|
||||
|
||||
def run(coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
def test_command_list_filter_and_lifecycle() -> None:
|
||||
container = build_container()
|
||||
|
||||
commands = container.plugins.list_commands()
|
||||
palette = container.plugins.list_commands(location="command_palette")
|
||||
|
||||
assert [item.command_id for item in commands] == ["text-tools.uppercase-selection"]
|
||||
assert palette[0].plugin_id == "text-tools"
|
||||
assert palette[0].icon == "edit"
|
||||
assert palette[0].when == ["editor.has_selection"]
|
||||
|
||||
container.plugins.disable("text-tools")
|
||||
assert container.plugins.list_commands() == []
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
run(
|
||||
container.plugins.execute_command(
|
||||
"text-tools.uppercase-selection",
|
||||
{},
|
||||
PluginCommandContext(selection="hello"),
|
||||
)
|
||||
)
|
||||
assert exc.value.code == "PLUGIN_COMMAND_NOT_FOUND"
|
||||
|
||||
container.plugins.enable("text-tools")
|
||||
assert len(container.plugins.list_commands()) == 1
|
||||
|
||||
|
||||
def test_command_executes_with_scoped_context_and_settings() -> None:
|
||||
container = build_container()
|
||||
container.plugins.update_settings("text-tools", 1, {"result_limit": 4})
|
||||
|
||||
result = run(
|
||||
container.plugins.execute_command(
|
||||
"text-tools.uppercase-selection",
|
||||
{},
|
||||
PluginCommandContext(
|
||||
vault_id="default",
|
||||
note_id="note_private",
|
||||
file_path="private.md",
|
||||
selection="abcdef",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.effect.type == "notification"
|
||||
assert result.effect.payload.model_dump() == {
|
||||
"level": "success",
|
||||
"message": "ABCD",
|
||||
}
|
||||
|
||||
|
||||
def test_echo_command_returns_none_for_empty_message() -> None:
|
||||
host = DeclarativePluginHost()
|
||||
|
||||
empty = run(host.execute_command("echo", {}, {}, {}, lambda _: None))
|
||||
populated = run(
|
||||
host.execute_command("echo", {"message": "hello"}, {}, {}, lambda _: None)
|
||||
)
|
||||
|
||||
assert isinstance(empty, PluginNoEffect)
|
||||
assert populated.type == "notification"
|
||||
assert populated.payload.message == "hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("effect_type", "payload"),
|
||||
[
|
||||
("none", {"unexpected": True}),
|
||||
("notification", {"level": "debug", "message": "invalid"}),
|
||||
("navigate", {"route": "https://example.com"}),
|
||||
("refresh", {"scope": "everything"}),
|
||||
("job", {"job_id": "invalid job id"}),
|
||||
],
|
||||
)
|
||||
def test_command_effect_rejects_untrusted_payloads(effect_type, payload) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(PluginCommandEffect).validate_python(
|
||||
{"type": effect_type, "payload": payload}
|
||||
)
|
||||
|
||||
|
||||
def test_command_rejects_missing_context_and_invalid_arguments() -> None:
|
||||
container = build_container()
|
||||
|
||||
with pytest.raises(ExtensionError) as context_error:
|
||||
run(
|
||||
container.plugins.execute_command(
|
||||
"text-tools.uppercase-selection", {}, PluginCommandContext()
|
||||
)
|
||||
)
|
||||
assert context_error.value.code == "PLUGIN_COMMAND_CONTEXT_INVALID"
|
||||
|
||||
with pytest.raises(ExtensionError) as argument_error:
|
||||
run(
|
||||
container.plugins.execute_command(
|
||||
"text-tools.uppercase-selection",
|
||||
{"unknown": True},
|
||||
PluginCommandContext(selection="hello"),
|
||||
)
|
||||
)
|
||||
assert argument_error.value.code == "PLUGIN_COMMAND_ARGUMENT_INVALID"
|
||||
|
||||
audit = container.plugins.commands.audit_events()
|
||||
assert [event.error_code for event in audit[-2:]] == [
|
||||
"PLUGIN_COMMAND_CONTEXT_INVALID",
|
||||
"PLUGIN_COMMAND_ARGUMENT_INVALID",
|
||||
]
|
||||
# 审计事件不得携带参数、正文选区或返回 effect。
|
||||
assert "hello" not in repr(audit)
|
||||
|
||||
|
||||
def test_command_only_receives_declared_context() -> None:
|
||||
class CapturingHost(DeclarativePluginHost):
|
||||
def __init__(self) -> None:
|
||||
self.context = None
|
||||
|
||||
async def execute_command(
|
||||
self, handler, arguments, context, settings, resolve_secret
|
||||
):
|
||||
self.context = context
|
||||
return PluginNoEffect()
|
||||
|
||||
host = CapturingHost()
|
||||
runtime = PluginRuntime(ToolRegistry(), host=host)
|
||||
runtime.install(TEXT_TOOLS)
|
||||
runtime.enable("text-tools")
|
||||
|
||||
run(
|
||||
runtime.execute_command(
|
||||
"text-tools.uppercase-selection",
|
||||
{},
|
||||
PluginCommandContext(
|
||||
vault_id="default", note_id="note_private", selection="visible"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert host.context == {"selection": "visible"}
|
||||
|
||||
|
||||
def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None:
|
||||
class SecretHost(DeclarativePluginHost):
|
||||
def __init__(self) -> None:
|
||||
self.secret = None
|
||||
self.denied_code = None
|
||||
|
||||
async def execute_command(
|
||||
self, handler, arguments, context, settings, resolve_secret
|
||||
):
|
||||
self.secret = resolve_secret("api_key")
|
||||
try:
|
||||
resolve_secret("undeclared")
|
||||
except ExtensionError as exc:
|
||||
self.denied_code = exc.code
|
||||
return PluginNoEffect()
|
||||
|
||||
host = SecretHost()
|
||||
runtime = PluginRuntime(ToolRegistry(), host=host)
|
||||
package = tmp_path / "secret-command"
|
||||
package.mkdir()
|
||||
(package / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: secret-command
|
||||
name: Secret Command
|
||||
version: 1.0.0
|
||||
permissions: [secrets.use]
|
||||
contributes:
|
||||
commands: [secret-command.run]
|
||||
settings_sections: [secret-command.general]
|
||||
backend:
|
||||
type: internal_rpc
|
||||
transport: none
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "commands.yaml").write_text(
|
||||
"""
|
||||
commands:
|
||||
- command_id: secret-command.run
|
||||
title: Secret Command
|
||||
locations: [command_palette]
|
||||
secrets: [api_key]
|
||||
handler: echo
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "settings.yaml").write_text(
|
||||
"""
|
||||
section_id: secret-command.general
|
||||
schema_version: 1
|
||||
fields:
|
||||
- key: api_key
|
||||
label: API Key
|
||||
type: secret
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime.install(package)
|
||||
runtime.set_permissions("secret-command", ["secrets.use"])
|
||||
runtime.enable("secret-command")
|
||||
runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret")
|
||||
|
||||
run(
|
||||
runtime.execute_command(
|
||||
"secret-command.run",
|
||||
{},
|
||||
PluginCommandContext(selection="visible"),
|
||||
)
|
||||
)
|
||||
|
||||
assert host.secret == "runtime-only-secret"
|
||||
assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED"
|
||||
assert "runtime-only-secret" not in repr(runtime.commands.audit_events())
|
||||
|
||||
|
||||
def test_settings_schema_contains_defaults_and_hides_secret() -> None:
|
||||
container = build_container()
|
||||
|
||||
schema = container.plugins.get_settings("text-tools")
|
||||
by_key = {field.key: field for field in schema.fields}
|
||||
|
||||
assert schema.schema_version == 1
|
||||
assert schema.values == {
|
||||
"result_limit": 100,
|
||||
"label_prefix": "",
|
||||
"output_style": "notification",
|
||||
"enabled_hint": True,
|
||||
}
|
||||
assert "api_key" not in schema.values
|
||||
assert schema.secrets["api_key"].configured is False
|
||||
assert by_key["api_key"].type == PluginSettingType.secret
|
||||
|
||||
|
||||
def test_settings_update_validates_version_type_bounds_and_secret_boundary() -> None:
|
||||
container = build_container()
|
||||
|
||||
updated = container.plugins.update_settings(
|
||||
"text-tools", 1, {"result_limit": 20, "output_style": "compact"}
|
||||
)
|
||||
assert updated.values["result_limit"] == 20
|
||||
assert updated.values["output_style"] == "compact"
|
||||
|
||||
cases = [
|
||||
(2, {}, "PLUGIN_SETTINGS_VERSION_CONFLICT"),
|
||||
(1, {"result_limit": 0}, "PLUGIN_SETTINGS_FIELD_INVALID"),
|
||||
(1, {"enabled_hint": "yes"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
|
||||
(1, {"output_style": "unknown"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
|
||||
(1, {"api_key": "plaintext"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
|
||||
(1, {"unknown": True}, "PLUGIN_SETTINGS_FIELD_INVALID"),
|
||||
]
|
||||
for version, values, code in cases:
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.update_settings("text-tools", version, values)
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
def test_required_plain_setting_blocks_enable_until_configured(tmp_path: Path) -> None:
|
||||
package = tmp_path / "required-setting"
|
||||
package.mkdir()
|
||||
(package / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: required-setting
|
||||
name: Required Setting
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
commands: [required-setting.run]
|
||||
settings_sections: [required-setting.general]
|
||||
backend:
|
||||
type: internal_rpc
|
||||
transport: none
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "commands.yaml").write_text(
|
||||
"""
|
||||
commands:
|
||||
- command_id: required-setting.run
|
||||
title: Required Setting
|
||||
locations: [command_palette]
|
||||
handler: echo
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "settings.yaml").write_text(
|
||||
"""
|
||||
section_id: required-setting.general
|
||||
schema_version: 1
|
||||
fields:
|
||||
- key: endpoint
|
||||
label: Endpoint
|
||||
type: string
|
||||
required: true
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime = PluginRuntime(ToolRegistry())
|
||||
runtime.install(package)
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
runtime.enable("required-setting")
|
||||
assert exc.value.code == "PLUGIN_SETTINGS_REQUIRED"
|
||||
assert runtime.get("required-setting").status == "installed"
|
||||
|
||||
runtime.update_settings("required-setting", 1, {"endpoint": "local"})
|
||||
assert runtime.enable("required-setting").status == "ready"
|
||||
|
||||
|
||||
def test_secret_roundtrip_never_enters_plain_settings_storage() -> None:
|
||||
container = build_container()
|
||||
plaintext = "stage-d-secret-value"
|
||||
|
||||
status = container.plugins.put_setting_secret("text-tools", "api_key", plaintext)
|
||||
schema = container.plugins.get_settings("text-tools")
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
|
||||
|
||||
assert status.configured is True
|
||||
assert schema.secrets["api_key"].configured is True
|
||||
assert "api_key" not in schema.values
|
||||
assert plaintext not in settings_path.read_text(encoding="utf-8")
|
||||
assert plaintext not in credentials_path.read_text(encoding="utf-8")
|
||||
stored_settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
reference = stored_settings["text-tools"]["secret_refs"]["api_key"]
|
||||
assert reference.startswith("plugin.")
|
||||
assert len(reference) == 71
|
||||
assert "text-tools" not in reference and "api_key" not in reference
|
||||
assert container.credentials.resolve(reference) == plaintext
|
||||
|
||||
deleted = container.plugins.delete_setting_secret("text-tools", "api_key")
|
||||
assert deleted.configured is False
|
||||
assert container.credentials.resolve(reference) is None
|
||||
|
||||
|
||||
def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None:
|
||||
container = build_container()
|
||||
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
|
||||
container.plugins.put_setting_secret("text-tools", "api_key", "temporary")
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
reference = json.loads(settings_path.read_text(encoding="utf-8"))[
|
||||
"text-tools"
|
||||
]["secret_refs"]["api_key"]
|
||||
|
||||
container.plugins.uninstall("text-tools")
|
||||
|
||||
stored = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
assert "text-tools" not in stored
|
||||
assert container.credentials.resolve(reference) is None
|
||||
|
||||
|
||||
def test_plugin_secret_reference_has_fixed_credential_safe_length() -> None:
|
||||
reference = _secret_reference("p" * 512, "k" * 128)
|
||||
|
||||
assert reference.startswith("plugin.")
|
||||
assert len(reference) <= 128
|
||||
|
||||
|
||||
def test_tampered_secret_reference_cannot_cross_credential_namespace() -> None:
|
||||
container = build_container()
|
||||
container.credentials.put("openai", "provider-private-secret")
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"text-tools": {
|
||||
"schema_version": 1,
|
||||
"values": {},
|
||||
"secret_refs": {"api_key": "openai"},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ExtensionError) as read_error:
|
||||
container.plugins.get_settings("text-tools")
|
||||
with pytest.raises(ExtensionError) as uninstall_error:
|
||||
container.plugins.uninstall("text-tools")
|
||||
|
||||
assert read_error.value.code == "PLUGIN_STORAGE_ERROR"
|
||||
assert uninstall_error.value.code == "PLUGIN_STORAGE_ERROR"
|
||||
assert container.credentials.resolve("openai") == "provider-private-secret"
|
||||
|
||||
|
||||
def test_secret_delete_restores_reference_when_credential_delete_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
container = build_container()
|
||||
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
original = settings_path.read_text(encoding="utf-8")
|
||||
reference = _secret_reference("text-tools", "api_key")
|
||||
|
||||
def fail_delete(_credential_id: str) -> bool:
|
||||
raise CredentialStoreError("injected delete failure")
|
||||
|
||||
monkeypatch.setattr(container.credentials, "delete", fail_delete)
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.delete_setting_secret("text-tools", "api_key")
|
||||
|
||||
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
|
||||
assert settings_path.read_text(encoding="utf-8") == original
|
||||
assert container.credentials.resolve(reference) == "keep-me"
|
||||
|
||||
|
||||
def test_uninstall_restores_settings_when_atomic_secret_delete_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
container = build_container()
|
||||
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
|
||||
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
original = settings_path.read_text(encoding="utf-8")
|
||||
reference = _secret_reference("text-tools", "api_key")
|
||||
|
||||
def fail_delete_many(_credential_ids: list[str]) -> set[str]:
|
||||
raise CredentialStoreError("injected batch delete failure")
|
||||
|
||||
monkeypatch.setattr(container.credentials, "delete_many", fail_delete_many)
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.uninstall("text-tools")
|
||||
|
||||
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
|
||||
assert settings_path.read_text(encoding="utf-8") == original
|
||||
assert container.credentials.resolve(reference) == "keep-me"
|
||||
assert container.plugins.get("text-tools").manifest.plugin_id == "text-tools"
|
||||
|
||||
|
||||
def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None:
|
||||
invalid_command = tmp_path / "invalid-command"
|
||||
invalid_command.mkdir()
|
||||
(invalid_command / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: invalid-command
|
||||
name: Invalid Command
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
commands: [other.run]
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(invalid_command / "commands.yaml").write_text(
|
||||
"""
|
||||
commands:
|
||||
- command_id: other.run
|
||||
title: Invalid
|
||||
locations: [command_palette]
|
||||
handler: echo
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
invalid_settings = tmp_path / "invalid-settings"
|
||||
invalid_settings.mkdir()
|
||||
(invalid_settings / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: invalid-settings
|
||||
name: Invalid Settings
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
settings_sections: [invalid-settings.general]
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(invalid_settings / "settings.yaml").write_text(
|
||||
"""
|
||||
section_id: invalid-settings.general
|
||||
schema_version: 1
|
||||
fields:
|
||||
- key: token
|
||||
label: Token
|
||||
type: secret
|
||||
default: leaked-default
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime = PluginRuntime(ToolRegistry())
|
||||
with pytest.raises(ExtensionError) as command_error:
|
||||
runtime.install(invalid_command)
|
||||
assert command_error.value.code == "PLUGIN_COMMAND_INVALID"
|
||||
|
||||
with pytest.raises(ExtensionError) as settings_error:
|
||||
runtime.install(invalid_settings)
|
||||
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bound", [".nan", ".inf", "-.inf"])
|
||||
def test_non_finite_setting_bounds_are_rejected(tmp_path: Path, bound: str) -> None:
|
||||
package = tmp_path / f"invalid-bound-{bound.replace('.', 'dot').replace('-', 'neg')}"
|
||||
package.mkdir()
|
||||
(package / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: invalid-bound
|
||||
name: Invalid Bound
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
settings_sections: [invalid-bound.general]
|
||||
backend:
|
||||
type: none
|
||||
transport: none
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "settings.yaml").write_text(
|
||||
f"""
|
||||
section_id: invalid-bound.general
|
||||
schema_version: 1
|
||||
fields:
|
||||
- key: limit
|
||||
label: Limit
|
||||
type: number
|
||||
minimum: {bound}
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
PluginRuntime(ToolRegistry()).install(package)
|
||||
|
||||
assert exc.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
|
||||
assert "must be finite" in exc.value.message
|
||||
|
||||
|
||||
def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None:
|
||||
package = tmp_path / "null-commands"
|
||||
package.mkdir()
|
||||
(package / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: null-commands
|
||||
name: Null Commands
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
commands: []
|
||||
backend:
|
||||
type: internal_rpc
|
||||
transport: none
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "commands.yaml").write_text("commands:\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
PluginRuntime(ToolRegistry()).install(package)
|
||||
|
||||
assert exc.value.code == "EXTENSION_MANIFEST_INVALID"
|
||||
|
||||
|
||||
def test_external_command_schema_reference_is_rejected(tmp_path: Path) -> None:
|
||||
package = tmp_path / "external-ref"
|
||||
package.mkdir()
|
||||
(package / "plugin.yaml").write_text(
|
||||
"""
|
||||
id: external-ref
|
||||
name: External Ref
|
||||
version: 1.0.0
|
||||
contributes:
|
||||
commands: [external-ref.run]
|
||||
backend:
|
||||
type: internal_rpc
|
||||
transport: none
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "commands.yaml").write_text(
|
||||
"""
|
||||
commands:
|
||||
- command_id: external-ref.run
|
||||
title: External Ref
|
||||
locations: [command_palette]
|
||||
handler: echo
|
||||
parameters:
|
||||
$ref: file:///host/private-schema.json
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
PluginRuntime(ToolRegistry()).install(package)
|
||||
|
||||
assert exc.value.code == "PLUGIN_COMMAND_INVALID"
|
||||
assert "External JSON Schema reference" in exc.value.message
|
||||
|
||||
|
||||
def test_settings_missing_and_secret_field_errors_are_stable() -> None:
|
||||
container = build_container()
|
||||
|
||||
with pytest.raises(ExtensionError) as missing:
|
||||
container.plugins.get_settings("does-not-exist")
|
||||
assert missing.value.code == "PLUGIN_NOT_FOUND"
|
||||
|
||||
with pytest.raises(ExtensionError) as field:
|
||||
container.plugins.put_setting_secret("text-tools", "result_limit", "secret")
|
||||
assert field.value.code == "PLUGIN_SECRET_FIELD_NOT_FOUND"
|
||||
|
||||
with pytest.raises(ExtensionError) as empty:
|
||||
container.plugins.put_setting_secret("text-tools", "api_key", "")
|
||||
assert empty.value.code == "PLUGIN_SECRET_VALUE_INVALID"
|
||||
|
||||
|
||||
def test_corrupted_plugin_settings_namespace_returns_stable_error() -> None:
|
||||
container = build_container()
|
||||
settings_path = get_settings().data_dir / "plugins" / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings_path.write_text('{"text-tools": []}', encoding="utf-8")
|
||||
|
||||
with pytest.raises(ExtensionError) as exc:
|
||||
container.plugins.get_settings("text-tools")
|
||||
|
||||
assert exc.value.code == "PLUGIN_STORAGE_ERROR"
|
||||
|
||||
with pytest.raises(ExtensionError) as secret_exc:
|
||||
container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan")
|
||||
|
||||
assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR"
|
||||
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
|
||||
credential_ids = (
|
||||
json.loads(credentials_path.read_text(encoding="utf-8")).keys()
|
||||
if credentials_path.exists()
|
||||
else []
|
||||
)
|
||||
assert not any(item.startswith("plugin.") for item in credential_ids)
|
||||
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from app.schema_security import (
|
||||
ExternalSchemaReferenceError,
|
||||
UnresolvableLocalSchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
{"$ref": "file:///host/private-schema.json"},
|
||||
{"properties": {"value": {"$ref": "https://schema.invalid/value.json"}}},
|
||||
{"allOf": [{"$dynamicRef": "https://schema.invalid/dynamic"}]},
|
||||
],
|
||||
)
|
||||
def test_external_json_schema_references_are_rejected(schema) -> None:
|
||||
with pytest.raises(ExternalSchemaReferenceError):
|
||||
reject_external_schema_references(schema)
|
||||
|
||||
|
||||
def test_local_json_schema_fragment_reference_is_allowed() -> None:
|
||||
reject_external_schema_references(
|
||||
{
|
||||
"$defs": {"value": {"type": "string"}},
|
||||
"properties": {"value": {"$ref": "#/$defs/value"}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reference", ["#/$defs/missing", "#missing-anchor"])
|
||||
def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None:
|
||||
with pytest.raises(UnresolvableLocalSchemaReferenceError):
|
||||
reject_external_schema_references({"type": "object", "$ref": reference})
|
||||
|
||||
|
||||
def test_root_reference_cannot_use_anchor_from_nested_schema_resource() -> None:
|
||||
schema = {
|
||||
"$defs": {
|
||||
"nested": {
|
||||
"$id": "nested",
|
||||
"$anchor": "inside",
|
||||
"type": "string",
|
||||
}
|
||||
},
|
||||
"properties": {"value": {"$ref": "#inside"}},
|
||||
}
|
||||
|
||||
with pytest.raises(UnresolvableLocalSchemaReferenceError):
|
||||
reject_external_schema_references(schema)
|
||||
|
||||
|
||||
def test_nested_schema_resource_can_resolve_its_own_anchor() -> None:
|
||||
schema = {
|
||||
"$defs": {
|
||||
"nested": {
|
||||
"$id": "nested",
|
||||
"$anchor": "inside",
|
||||
"allOf": [{"$ref": "#inside"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reject_external_schema_references(schema)
|
||||
Generated
+2
@@ -374,6 +374,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "sqlite-vec" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@@ -390,6 +391,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "referencing", specifier = ">=0.36,<1.0" },
|
||||
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user