diff --git a/.gitignore b/.gitignore index 44cc045..84abf25 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,12 @@ backend/.env # 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交) backend/data/*.db* backend/data/credentials/ +# 阶段验收笔记(验收用,不提交) +backend/data/vault/验收/ +# 本机 MCP 配置、授权状态及服务器工作目录不得提交。 +backend/data/mcp/ +server.json +servers.json # Editors and operating systems .idea/ diff --git a/README.md b/README.md index c8f3851..60ca8e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。 -> 当前基线:2026-08-30。第一阶段 Web 联调版的前端页面、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 预设与本地加密凭据链路均已实现;Tauri Host、Stronghold、真实桌面文件系统和 Sync Server 尚未接入。 +> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1(stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。 ## 当前目录 @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 157 项测试、前端 29 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 218 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 @@ -133,6 +133,8 @@ pnpm test | [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 | | [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core | | [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 | +| [Plugin Command 与 Settings](docs/development/Plugin-Command与Settings开发说明.md) | Command Registry、Settings Schema、Secret 引用与联调边界 | +| [Plugin Command 与 Settings 复盘](docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md) | 阶段 D 连续审阅发现的安全、事务、Schema 与运行时契约问题 | | [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 | | [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 | | [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 | @@ -146,6 +148,6 @@ pnpm test - 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。 - 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。 - 当前已实现接口见 `docs/contracts/后端接口契约-开发版.md`,第二阶段规划接口见 `docs/contracts/第二阶段接口契约-开发版.md`;已实现能力以 `/openapi.json` 为准。 -- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/contracts/前端页面需求说明-开发版.md`。 +- 前端页面、交互、状态管理及当前阶段后续页面需求见 `docs/contracts/前端页面需求说明-开发版.md`。 - 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/guides/Git使用细则-团队开发版.md`。 - CI 检查、产物、发布和回滚规范见 `docs/guides/CI-CD细则-团队开发版.md`。 diff --git a/backend/README.md b/backend/README.md index 3d4fde4..a857f76 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。 -当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama;OpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。 +当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、stdio MCP Host、Plugin Command/Settings、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama;OpenAI Responses、Anthropic Messages、操作系统级 Plugin 沙箱和真实语音模型仍属于后续阶段。 ```powershell uv sync @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 136 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index df90158..fb53ed2 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -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}") diff --git a/backend/app/container.py b/backend/app/container.py index 9263ca9..ee05cd8 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -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, ) diff --git a/backend/app/contracts.py b/backend/app/contracts.py index ce73a7a..0b3eb6a 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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) diff --git a/backend/app/extensions/__init__.py b/backend/app/extensions/__init__.py index ed2e570..41c8599 100644 --- a/backend/app/extensions/__init__.py +++ b/backend/app/extensions/__init__.py @@ -1,9 +1,5 @@ -from app.extensions.runtime import ( - AgentConfiguration, - ExtensionError, - PluginRuntime, - SkillRuntime, -) +from app.extensions.errors import ExtensionError +from app.extensions.runtime import AgentConfiguration, PluginRuntime, SkillRuntime from app.extensions.mcp import McpBridge, McpBridgeError __all__ = [ diff --git a/backend/app/extensions/contributions.py b/backend/app/extensions/contributions.py new file mode 100644 index 0000000..5402f42 --- /dev/null +++ b/backend/app/extensions/contributions.py @@ -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}, + ) diff --git a/backend/app/extensions/errors.py b/backend/app/extensions/errors.py new file mode 100644 index 0000000..61ed7a8 --- /dev/null +++ b/backend/app/extensions/errors.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + + +class ExtensionError(RuntimeError): + """Extension Core 对 API 暴露的稳定领域错误。""" + + def __init__( + self, + code: str, + message: str, + *, + status_code: int = 422, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.details = details or {} diff --git a/backend/app/extensions/mcp.py b/backend/app/extensions/mcp.py index 68f056b..8247edd 100644 --- a/backend/app/extensions/mcp.py +++ b/backend/app/extensions/mcp.py @@ -10,14 +10,19 @@ import asyncio import json import os import queue +import re +import signal import subprocess import threading from collections import deque +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Callable +from typing import Any, Protocol +from urllib.parse import urljoin, urlsplit +import httpx from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError @@ -29,6 +34,10 @@ from app.contracts import ( PluginHostStatus, ToolDefinition, ) +from app.schema_security import ( + SchemaReferenceError, + reject_external_schema_references, +) MCP_PROTOCOL_VERSION = "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS = { @@ -41,6 +50,7 @@ MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024 MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024 MAX_MCP_TOOLS = 500 MAX_MCP_LIST_PAGES = 100 +_SSE_NEWLINE = re.compile(rb"\r\n?|\n") class McpBridgeError(RuntimeError): @@ -70,12 +80,14 @@ class McpStdioClient: command: list[str], *, cwd: Path, + environment: dict[str, str] | None = None, on_seen: Callable[[], None], on_broken: Callable[[str], None], on_tools_changed: Callable[[], None], ) -> None: self.command = command self.cwd = cwd + self.environment = environment or {} self.on_seen = on_seen self.on_broken = on_broken self.on_tools_changed = on_tools_changed @@ -93,8 +105,14 @@ class McpStdioClient: return # TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的 # 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。 - creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + creation_flags = ( + getattr(subprocess, "CREATE_NO_WINDOW", 0) + | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + if os.name == "nt" + else 0 + ) environment = _subprocess_environment() + environment.update(self.environment) environment.setdefault("PYTHONUNBUFFERED", "1") try: self.process = subprocess.Popen( @@ -110,6 +128,7 @@ class McpStdioClient: shell=False, env=environment, creationflags=creation_flags, + start_new_session=os.name != "nt", ) except OSError as exc: raise McpBridgeError( @@ -129,7 +148,7 @@ class McpStdioClient: timeout_code: str, response_error_code: str = "MCP_TOOL_CALL_FAILED", ) -> dict[str, Any]: - request_id, pending = self.begin_request(method, params) + request_id, pending = self.begin_request(method, params, timeout=timeout) return self.wait_response( request_id, pending, @@ -139,7 +158,7 @@ class McpStdioClient: ) def begin_request( - self, method: str, params: dict[str, Any] + self, method: str, params: dict[str, Any], *, timeout: float | None = None ) -> tuple[int, _PendingRequest]: self._ensure_running() with self._pending_lock: @@ -176,7 +195,9 @@ class McpStdioClient: except queue.Empty as exc: self.cancel(request_id, "Request timed out.") self.abandon(request_id) - raise McpBridgeError(timeout_code, "MCP request timed out.", status_code=504) from exc + raise McpBridgeError( + timeout_code, "MCP request timed out.", status_code=504 + ) from exc if isinstance(response, BaseException): raise response if "error" in response: @@ -209,9 +230,7 @@ class McpStdioClient: except McpBridgeError: pass - def abandon( - self, request_id: int, wake_error: BaseException | None = None - ) -> None: + def abandon(self, request_id: int, wake_error: BaseException | None = None) -> None: with self._pending_lock: pending = self._pending.pop(request_id, None) # asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程 @@ -236,15 +255,17 @@ class McpStdioClient: try: process.wait(timeout=2) except subprocess.TimeoutExpired: - process.terminate() + _terminate_process_tree(process) try: process.wait(timeout=2) except subprocess.TimeoutExpired: - process.kill() + _kill_process_tree(process) process.wait(timeout=2) finally: self._fail_pending( - McpBridgeError("PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503) + McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503 + ) ) self.process = None @@ -306,14 +327,17 @@ class McpStdioClient: { "jsonrpc": "2.0", "id": message["id"], - "error": {"code": -32601, "message": "Method not supported."}, + "error": { + "code": -32601, + "message": "Method not supported.", + }, } ) except (McpBridgeError, OSError, ValueError) as exc: failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}." finally: if failure and process.poll() is None: - process.terminate() + _terminate_process_tree(process) exit_code = process.poll() if exit_code is None: try: @@ -321,7 +345,9 @@ class McpStdioClient: except subprocess.TimeoutExpired: exit_code = None if not self._stopping: - message = failure or f"MCP host exited unexpectedly with code {exit_code}." + message = ( + failure or f"MCP host exited unexpectedly with code {exit_code}." + ) error = McpBridgeError( "PLUGIN_HOST_UNAVAILABLE", message, status_code=503 ) @@ -356,13 +382,498 @@ class McpStdioClient: item.response.put(error) +class McpHttpClient: + """MCP Streamable HTTP client supporting JSON and SSE POST responses.""" + + def __init__( + self, + url: str, + *, + headers: dict[str, str], + startup_timeout_seconds: float = 15, + on_seen: Callable[[], None], + on_broken: Callable[[str], None], + on_tools_changed: Callable[[], None], + ) -> None: + self.url = url + self.headers = headers + self.on_seen = on_seen + self.on_broken = on_broken + self.on_tools_changed = on_tools_changed + self._client = httpx.Client(follow_redirects=False, timeout=30) + self._pending_lock = threading.Lock() + self._pending: dict[int, _PendingRequest] = {} + self._next_id = 1 + self._session_id: str | None = None + self._protocol_version: str | None = None + self._stopping = False + self._stream_started = False + self._last_event_id: str | None = None + self._stop_event = threading.Event() + self._startup_timeout_seconds = startup_timeout_seconds + + def start(self) -> None: + return + + def set_protocol_version(self, version: str) -> None: + self._protocol_version = version + + def start_event_stream(self) -> None: + if self._stream_started: + return + self._stream_started = True + threading.Thread(target=self._event_stream_loop, daemon=True).start() + + def request( + self, + method: str, + params: dict[str, Any], + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: + request_id, pending = self.begin_request(method, params, timeout=timeout) + return self.wait_response( + request_id, + pending, + timeout=timeout, + timeout_code=timeout_code, + response_error_code=response_error_code, + ) + + def begin_request( + self, method: str, params: dict[str, Any], *, timeout: float | None = None + ) -> tuple[int, _PendingRequest]: + with self._pending_lock: + request_id = self._next_id + self._next_id += 1 + pending = _PendingRequest(response=queue.Queue(maxsize=1)) + self._pending[request_id] = pending + message = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + threading.Thread( + target=self._dispatch_request, + args=(request_id, message, timeout), + daemon=True, + ).start() + return request_id, pending + + def wait_response( + self, + request_id: int, + pending: _PendingRequest, + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: + try: + response = pending.response.get(timeout=timeout) + except queue.Empty as exc: + self.cancel(request_id, "Request timed out.") + self.abandon(request_id) + raise McpBridgeError( + timeout_code, "MCP request timed out.", status_code=504 + ) from exc + if isinstance(response, BaseException): + raise response + if "error" in response: + error = response.get("error") + message = ( + str(error.get("message", "MCP JSON-RPC error.")) + if isinstance(error, dict) + else "MCP JSON-RPC error." + ) + raise McpBridgeError(response_error_code, message) + result = response.get("result") + if not isinstance(result, dict): + raise McpBridgeError( + response_error_code, "MCP response result must be an object." + ) + return result + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + message: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + message["params"] = params + self._post_notification(message) + + def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None: + def send() -> None: + try: + self.notify( + "notifications/cancelled", + {"requestId": request_id, "reason": reason}, + ) + except McpBridgeError: + pass + + threading.Thread(target=send, daemon=True).start() + + def abandon(self, request_id: int, wake_error: BaseException | None = None) -> None: + with self._pending_lock: + pending = self._pending.pop(request_id, None) + if pending is not None and wake_error is not None: + try: + pending.response.put_nowait(wake_error) + except queue.Full: + pass + + def stop(self) -> None: + self._stopping = True + self._stop_event.set() + if self._session_id: + try: + request = self._client.build_request( + "DELETE", + self.url, + headers=self._request_headers(), + timeout=min(self._startup_timeout_seconds, 5), + ) + response = self._client.send(request, stream=True) + response.close() + except httpx.HTTPError: + pass + self._client.close() + self._fail_pending( + McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", "MCP HTTP client stopped.", status_code=503 + ) + ) + + def _dispatch_request( + self, + request_id: int, + message: dict[str, Any], + timeout: float | None, + ) -> None: + try: + response = self._post(message, timeout=timeout) + try: + self._capture_session(response) + content_type = response.headers.get("content-type", "").lower() + if response.status_code >= 400: + raise McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"MCP HTTP server returned status {response.status_code}.", + status_code=502, + ) + if "application/json" in content_type: + payload = _bounded_json_response(response) + self._deliver(payload) + elif "text/event-stream" in content_type: + delivered = False + for _event, _event_id, data in _iter_sse(response): + payload = _json_rpc_message(data) + self._handle_message(payload) + if payload.get("id") == request_id: + delivered = True + break + if not delivered: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", + "MCP SSE response ended before the matching JSON-RPC response.", + ) + else: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", + "MCP HTTP response has an unsupported Content-Type.", + ) + finally: + response.close() + except (McpBridgeError, httpx.HTTPError) as exc: + error = ( + exc + if isinstance(exc, McpBridgeError) + else McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"MCP HTTP request failed: {type(exc).__name__}.", + status_code=503, + ) + ) + self.abandon(request_id, error) + + def _post_notification(self, message: dict[str, Any]) -> None: + try: + timeout = ( + self._startup_timeout_seconds + if message.get("method") == "notifications/initialized" + else 10 + ) + response = self._post(message, timeout=timeout) + except httpx.HTTPError as exc: + raise McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"MCP HTTP notification failed: {type(exc).__name__}.", + status_code=503, + ) from exc + try: + self._capture_session(response) + if response.status_code not in {200, 202, 204}: + raise McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"MCP HTTP server rejected a notification with status {response.status_code}.", + ) + finally: + response.close() + + def _post( + self, message: dict[str, Any], *, timeout: float | None + ) -> httpx.Response: + encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.") + request = self._client.build_request( + "POST", + self.url, + content=encoded.encode("utf-8"), + headers=self._request_headers(), + timeout=timeout, + ) + return self._client.send(request, stream=True) + + def _request_headers(self) -> dict[str, str]: + headers = { + **self.headers, + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + if self._session_id: + headers["MCP-Session-Id"] = self._session_id + if self._protocol_version: + headers["MCP-Protocol-Version"] = self._protocol_version + return headers + + def _capture_session(self, response: httpx.Response) -> None: + session_id = response.headers.get("mcp-session-id") + if session_id is not None: + if ( + not session_id.isascii() + or not session_id.isprintable() + or len(session_id) > 1024 + ): + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP session id is invalid." + ) + self._session_id = session_id + + def _handle_message(self, message: dict[str, Any]) -> None: + self.on_seen() + if "id" in message and ("result" in message or "error" in message): + self._deliver(message) + elif message.get("method") == "notifications/tools/list_changed": + self.on_tools_changed() + + def _deliver(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + if not isinstance(request_id, int): + return + with self._pending_lock: + pending = self._pending.pop(request_id, None) + if pending: + pending.response.put(message) + + def _fail_pending(self, error: BaseException) -> None: + with self._pending_lock: + pending = list(self._pending.values()) + self._pending.clear() + for item in pending: + item.response.put(error) + + def _event_stream_loop(self) -> None: + while not self._stop_event.is_set(): + headers = {**self._request_headers(), "Accept": "text/event-stream"} + headers.pop("Content-Type", None) + if self._last_event_id: + headers["Last-Event-ID"] = self._last_event_id + try: + with self._client.stream( + "GET", self.url, headers=headers, timeout=None + ) as response: + if response.status_code == 405: + return + if response.status_code >= 400: + self.on_broken( + f"MCP HTTP event stream returned status {response.status_code}." + ) + return + if ( + "text/event-stream" + not in response.headers.get("content-type", "").lower() + ): + self.on_broken("MCP HTTP GET response is not an event stream.") + return + self._capture_session(response) + for _event, event_id, data in _iter_sse(response): + if event_id: + self._last_event_id = event_id + self._handle_message(_json_rpc_message(data)) + if self._stop_event.is_set(): + return + except (McpBridgeError, httpx.HTTPError): + if self._stopping: + return + self._stop_event.wait(0.25) + + +class McpLegacySseClient(McpHttpClient): + """Compatibility client for the deprecated 2024-11-05 HTTP+SSE transport.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._endpoint: str | None = None + self._endpoint_ready: queue.Queue[str | BaseException] = queue.Queue(maxsize=1) + + def start(self) -> None: + threading.Thread(target=self._event_loop, daemon=True).start() + try: + endpoint = self._endpoint_ready.get(timeout=self._startup_timeout_seconds) + except queue.Empty as exc: + raise McpBridgeError( + "MCP_INITIALIZE_FAILED", + "Legacy MCP SSE endpoint event timed out.", + status_code=504, + ) from exc + if isinstance(endpoint, BaseException): + raise endpoint + self._endpoint = endpoint + + def start_event_stream(self) -> None: + """The legacy client already owns its single GET event stream.""" + + return + + def _dispatch_request( + self, + request_id: int, + message: dict[str, Any], + timeout: float | None, + ) -> None: + try: + response = self._post(message, timeout=timeout) + try: + if response.status_code not in {200, 202, 204}: + raise McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"Legacy MCP endpoint returned status {response.status_code}.", + ) + finally: + response.close() + except (McpBridgeError, httpx.HTTPError) as exc: + error = ( + exc + if isinstance(exc, McpBridgeError) + else McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"Legacy MCP request failed: {type(exc).__name__}.", + status_code=503, + ) + ) + self.abandon(request_id, error) + + def _post( + self, message: dict[str, Any], *, timeout: float | None + ) -> httpx.Response: + if self._endpoint is None: + raise McpBridgeError( + "MCP_INITIALIZE_FAILED", "Legacy MCP endpoint is not ready." + ) + encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.") + request = self._client.build_request( + "POST", + self._endpoint, + content=encoded.encode("utf-8"), + headers={ + **self.headers, + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + timeout=timeout, + ) + return self._client.send(request, stream=True) + + def _event_loop(self) -> None: + try: + with self._client.stream( + "GET", + self.url, + headers={**self.headers, "Accept": "text/event-stream"}, + timeout=None, + ) as response: + if response.status_code >= 400: + raise McpBridgeError( + "MCP_HTTP_REQUEST_FAILED", + f"Legacy MCP SSE server returned status {response.status_code}.", + ) + if ( + "text/event-stream" + not in response.headers.get("content-type", "").lower() + ): + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", + "Legacy MCP GET response is not an event stream.", + ) + for event, _event_id, data in _iter_sse(response): + if self._endpoint is None and event == "endpoint": + endpoint = _legacy_endpoint_url(self.url, data) + self._endpoint_ready.put(endpoint) + self._endpoint = endpoint + continue + self._handle_message(_json_rpc_message(data)) + if not self._stopping: + self.on_broken("Legacy MCP SSE stream ended unexpectedly.") + except (McpBridgeError, httpx.HTTPError) as exc: + if self._endpoint is None: + self._endpoint_ready.put(exc) + elif not self._stopping: + self.on_broken(f"Legacy MCP SSE stream failed: {type(exc).__name__}.") + + @dataclass(slots=True) class _McpHost: backend: PluginBackend - client: McpStdioClient + client: _McpClient status: PluginHostStatus +class _McpClient(Protocol): + def start(self) -> None: ... + def request( + self, + method: str, + params: dict[str, Any], + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: ... + def begin_request( + self, method: str, params: dict[str, Any], *, timeout: float | None = None + ) -> tuple[int, _PendingRequest]: ... + def wait_response( + self, + request_id: int, + pending: _PendingRequest, + *, + timeout: float, + timeout_code: str, + response_error_code: str = "MCP_TOOL_CALL_FAILED", + ) -> dict[str, Any]: ... + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: ... + def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None: ... + def abandon( + self, request_id: int, wake_error: BaseException | None = None + ) -> None: ... + def stop(self) -> None: ... + + class McpBridge: """管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。""" @@ -379,19 +890,31 @@ class McpBridge: package_path: Path, declared_permissions: list[str], on_unavailable: Callable[[str, str], None], + *, + command_override: list[str] | None = None, + environment: dict[str, str] | None = None, + tool_source: str = "plugin", + transport_kind: str | None = None, + url: str | None = None, + headers: dict[str, str] | None = None, ) -> list[McpDiscoveredTool]: - if backend.transport != "stdio": + transport = transport_kind or backend.transport + if transport not in {"stdio", "streamable_http", "sse"}: raise McpBridgeError( "MCP_CAPABILITY_UNSUPPORTED", - "Phase C only supports the MCP stdio transport.", + f"Unsupported MCP transport: {transport}", status_code=501, ) - command = self._resolve_command(package_path, backend) - now = datetime.now(timezone.utc) + command = ( + command_override or self._resolve_command(package_path, backend) + if transport == "stdio" + else None + ) + now = datetime.now(UTC) status = PluginHostStatus( plugin_id=plugin_id, backend_type="mcp", - transport="stdio", + transport="stdio" if transport == "stdio" else "http", status=PluginHostState.starting, started_at=now, last_seen_at=now, @@ -401,7 +924,7 @@ class McpBridge: def seen() -> None: host = host_ref.get("host") if host: - host.status.last_seen_at = datetime.now(timezone.utc) + host.status.last_seen_at = datetime.now(UTC) def broken(message: str) -> None: host = host_ref.get("host") @@ -411,15 +934,36 @@ class McpBridge: on_unavailable(plugin_id, message) def tools_changed() -> None: - broken("MCP tool list changed; restart the Plugin Host to revalidate tools.") + broken( + "MCP tool list changed; restart the Plugin Host to revalidate tools." + ) - client = McpStdioClient( - command, - cwd=package_path, - on_seen=seen, - on_broken=broken, - on_tools_changed=tools_changed, - ) + if transport == "stdio": + assert command is not None + client: _McpClient = McpStdioClient( + command, + cwd=package_path, + environment=environment, + on_seen=seen, + on_broken=broken, + on_tools_changed=tools_changed, + ) + else: + if not url: + raise McpBridgeError( + "MCP_HOST_START_FAILED", "MCP HTTP transport requires a URL." + ) + client_type = ( + McpHttpClient if transport == "streamable_http" else McpLegacySseClient + ) + client = client_type( + url, + headers=headers or {}, + startup_timeout_seconds=backend.startup_timeout_seconds, + on_seen=seen, + on_broken=broken, + on_tools_changed=tools_changed, + ) host = _McpHost(backend=backend, client=client, status=status) host_ref["host"] = host with self._lock: @@ -462,15 +1006,28 @@ class McpBridge: if not isinstance(server_info, dict): server_info = {} status.protocol_version = str(version) + set_protocol_version = getattr(client, "set_protocol_version", None) + if callable(set_protocol_version): + set_protocol_version(str(version)) status.server_name = _optional_string(server_info.get("name")) status.server_version = _optional_string(server_info.get("version")) client.notify("notifications/initialized") + start_event_stream = getattr(client, "start_event_stream", None) + if callable(start_event_stream): + start_event_stream() discovered = self._discover_tools( - plugin_id, client, backend, declared_permissions + plugin_id, client, backend, declared_permissions, tool_source ) + if status.status == PluginHostState.unhealthy: + raise McpBridgeError( + "PLUGIN_HOST_UNAVAILABLE", + status.error + or "MCP event stream became unavailable during startup.", + status_code=503, + ) status.status = PluginHostState.ready status.tools_count = len(discovered) - status.last_seen_at = datetime.now(timezone.utc) + status.last_seen_at = datetime.now(UTC) status.error = None return discovered except McpBridgeError as exc: @@ -498,7 +1055,9 @@ class McpBridge: ) -> Any: host = self._host(plugin_id) rpc_id, pending = host.client.begin_request( - "tools/call", {"name": remote_name, "arguments": arguments} + "tools/call", + {"name": remote_name, "arguments": arguments}, + timeout=host.backend.tool_timeout_seconds, ) call_key = (plugin_id, request_id) with self._lock: @@ -515,9 +1074,7 @@ class McpBridge: host.client.cancel(rpc_id) host.client.abandon( rpc_id, - McpBridgeError( - "MCP_TOOL_CALL_FAILED", "MCP request was cancelled." - ), + McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request was cancelled."), ) raise except McpBridgeError as exc: @@ -527,7 +1084,9 @@ class McpBridge: self._calls.pop(call_key, None) encoded_size = len( - json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) ) if encoded_size > MAX_MCP_TOOL_RESULT_BYTES: raise ToolExecutionError( @@ -594,9 +1153,10 @@ class McpBridge: def _discover_tools( self, plugin_id: str, - client: McpStdioClient, + client: _McpClient, backend: PluginBackend, declared_permissions: list[str], + tool_source: str, ) -> list[McpDiscoveredTool]: discovered: list[McpDiscoveredTool] = [] cursor: str | None = None @@ -612,11 +1172,12 @@ class McpBridge: raw_tools = result.get("tools") if not isinstance(raw_tools, list): raise McpBridgeError( - "MCP_TOOL_SCHEMA_INVALID", "MCP tools/list must return a tools array." + "MCP_TOOL_SCHEMA_INVALID", + "MCP tools/list must return a tools array.", ) for raw in raw_tools: discovered.append( - self._map_tool(plugin_id, raw, declared_permissions) + self._map_tool(plugin_id, raw, declared_permissions, tool_source) ) if len(discovered) > MAX_MCP_TOOLS: raise McpBridgeError( @@ -628,7 +1189,8 @@ class McpBridge: break if not isinstance(next_cursor, str) or not next_cursor: raise McpBridgeError( - "MCP_TOOL_SCHEMA_INVALID", "MCP nextCursor must be a non-empty string." + "MCP_TOOL_SCHEMA_INVALID", + "MCP nextCursor must be a non-empty string.", ) cursor = next_cursor else: @@ -644,7 +1206,10 @@ class McpBridge: @staticmethod def _map_tool( - plugin_id: str, raw: Any, declared_permissions: list[str] + plugin_id: str, + raw: Any, + declared_permissions: list[str], + tool_source: str = "plugin", ) -> McpDiscoveredTool: if not isinstance(raw, dict): raise McpBridgeError( @@ -659,9 +1224,7 @@ class McpBridge: len(remote_name) > 128 or not remote_name[0].isalnum() or not all( - character.islower() - or character.isdigit() - or character in "._-" + character.islower() or character.isdigit() or character in "._-" for character in remote_name ) ): @@ -677,14 +1240,18 @@ class McpBridge: ) 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 McpBridgeError( "MCP_TOOL_SCHEMA_INVALID", - f"Invalid MCP tool schema for {remote_name}: {exc.message}", + f"Invalid MCP tool schema for {remote_name}: {message}", ) from exc metadata = raw.get("_meta") permission = ( - metadata.get("notesagent/permission") if isinstance(metadata, dict) else None + metadata.get("notesagent/permission") + if isinstance(metadata, dict) + else None ) if permission is not None and ( not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS @@ -703,10 +1270,12 @@ class McpBridge: remote_name=remote_name, definition=ToolDefinition( name=f"{plugin_id}.{remote_name}", - description=description if isinstance(description, str) else remote_name, + description=description + if isinstance(description, str) + else remote_name, parameters=schema, permission=permission, - source="plugin", + source=tool_source, ), ) @@ -783,3 +1352,179 @@ def _subprocess_environment() -> dict[str, str]: environment["PYTHONUNBUFFERED"] = "1" environment["PYTHONIOENCODING"] = "utf-8" return environment + + +def _bounded_json_response(response: httpx.Response) -> dict[str, Any]: + content_length = response.headers.get("content-length") + if ( + content_length + and content_length.isdigit() + and int(content_length) > MAX_MCP_MESSAGE_BYTES + ): + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is too large." + ) + chunks: list[bytes] = [] + size = 0 + for chunk in response.iter_bytes(): + size += len(chunk) + if size > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is too large." + ) + chunks.append(chunk) + try: + payload = json.loads(b"".join(chunks)) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is not valid JSON." + ) from exc + if not isinstance(payload, dict) or payload.get("jsonrpc") != "2.0": + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is not a JSON-RPC message." + ) + return payload + + +def _bounded_sse_lines(response: httpx.Response): + """Split UTF-8 lines without httpx.iter_lines()'s unbounded line buffer. + + Check each segment before appending it, including partial/no-newline input. + SSE allows LF, CR and CRLF; a CRLF pair can span network chunks. + """ + + pending = bytearray() + event_size = 0 + skip_lf = False + first_line = True + for chunk in response.iter_bytes(): + offset = 0 + if skip_lf and chunk: + offset = int(chunk.startswith(b"\n")) + skip_lf = False + for match in _SSE_NEWLINE.finditer(chunk, offset): + start, end = match.span() + segment = memoryview(chunk)[offset:start] + if event_size + len(pending) + len(segment) + 1 > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large." + ) + pending.extend(segment) + line = pending.decode("utf-8", errors="replace") + event_size += len(pending) + 1 + pending.clear() + if first_line: + line = line.removeprefix("\ufeff") + first_line = False + if not line: + event_size = 0 + yield line + skip_lf = chunk[end - 1 : end] == b"\r" and end == len(chunk) + offset = end + tail = memoryview(chunk)[offset:] + if event_size + len(pending) + len(tail) > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large." + ) + pending.extend(tail) + if pending: + line = pending.decode("utf-8", errors="replace") + yield line.removeprefix("\ufeff") if first_line else line + + +def _iter_sse(response: httpx.Response): + event = "message" + event_id: str | None = None + data_lines: list[str] = [] + size = 0 + for line in _bounded_sse_lines(response): + size += len(line.encode("utf-8")) + 1 + if size > MAX_MCP_MESSAGE_BYTES: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large." + ) + if line == "": + if data_lines: + yield event, event_id, "\n".join(data_lines) + event, event_id, data_lines, size = "message", None, [], 0 + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event = value + elif field == "id" and "\x00" not in value: + event_id = value + elif field == "data": + data_lines.append(value) + if data_lines: + yield event, event_id, "\n".join(data_lines) + + +def _json_rpc_message(data: str) -> dict[str, Any]: + try: + message = json.loads(data) + except json.JSONDecodeError as exc: + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP SSE data is not valid JSON." + ) from exc + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", "MCP SSE data is not a JSON-RPC message." + ) + return message + + +def _legacy_endpoint_url(source_url: str, endpoint: str) -> str: + target = urljoin(source_url, endpoint.strip()) + source_parts = urlsplit(source_url) + target_parts = urlsplit(target) + if ( + target_parts.scheme not in {"http", "https"} + or target_parts.username is not None + or target_parts.password is not None + or (source_parts.scheme, source_parts.hostname, source_parts.port) + != (target_parts.scheme, target_parts.hostname, target_parts.port) + ): + raise McpBridgeError( + "MCP_HTTP_RESPONSE_INVALID", + "Legacy MCP endpoint must use the same origin as the configured SSE URL.", + ) + return target + + +def _terminate_process_tree(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + try: + if os.name == "nt": + subprocess.run( + ["taskkill.exe", "/PID", str(process.pid), "/T"], + check=False, + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + timeout=2, + ) + else: + os.killpg(process.pid, signal.SIGTERM) + except (OSError, subprocess.SubprocessError): + process.terminate() + + +def _kill_process_tree(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + try: + if os.name == "nt": + subprocess.run( + ["taskkill.exe", "/PID", str(process.pid), "/T", "/F"], + check=False, + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + timeout=2, + ) + else: + os.killpg(process.pid, signal.SIGKILL) + except (OSError, subprocess.SubprocessError): + process.kill() diff --git a/backend/app/extensions/mcp_registry.py b/backend/app/extensions/mcp_registry.py new file mode 100644 index 0000000..a81aced --- /dev/null +++ b/backend/app/extensions/mcp_registry.py @@ -0,0 +1,1010 @@ +"""Independent, user-managed MCP server registry for development builds.""" + +from __future__ import annotations + +import hashlib +import json +import re +import threading +from datetime import UTC, datetime +from functools import wraps +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model + +from app.agent.permissions import KNOWN_PERMISSIONS +from app.agent.tools import ToolExecutionContext, ToolRegistry +from app.contracts import ( + McpServer, + McpServerConfig, + McpServerCreateRequest, + McpServerSecretStatus, + McpServerTransport, + McpServerUpdateRequest, + McpToolSummary, + PluginBackend, + PluginHostState, +) +from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool +from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore + +_ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$") +_HEADER_KEY = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$") +_RESERVED_HEADERS = { + "accept", + "content-length", + "content-type", + "host", + "mcp-protocol-version", + "mcp-session-id", +} +_SERVER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +_MAX_MCP_SERVERS = 256 + + +class _McpConnectionBackend(PluginBackend): + """Bridge adapter for the independent server's float timeout contract. + + Plugin manifests retain their integer/60-second startup restrictions. + Reusing that validation here used to reject valid 120-second server configs. + """ + + startup_timeout_seconds: float = Field(default=15, ge=1, le=120) + tool_timeout_seconds: float = Field(default=30, ge=1, le=300) + + +class _McpServerRecord(McpServerConfig): + """Validated on-disk representation with defaults for older C.1 records.""" + + version: int = Field(default=1, ge=1) + secret_environment_version: Literal[1, 2] = 1 + enabled: bool = False + approved_digest: str | None = Field( + default=None, min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$" + ) + tested_digest: str | None = Field( + default=None, min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$" + ) + last_tested_at: datetime | None = None + last_test_succeeded: bool | None = None + + +class McpRegistryError(RuntimeError): + def __init__(self, code: str, message: str, *, status_code: int = 422) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +def _serialized_lifecycle(method): + """Serialize lifecycle mutations without blocking MCP failure callbacks.""" + + @wraps(method) + def wrapped(self, *args, **kwargs): + with self._lifecycle_lock: + return method(self, *args, **kwargs) + + return wrapped + + +class McpServerRegistry: + """Persists configuration and owns stdio host/tool lifecycles.""" + + def __init__( + self, + registry: ToolRegistry, + credentials: EncryptedCredentialStore, + data_dir: Path, + *, + allow_process_launch: bool, + bridge: McpBridge | None = None, + ) -> None: + self.tools = registry + self.credentials = credentials + self.data_dir = data_dir + self.allow_process_launch = allow_process_launch + self.bridge = bridge or McpBridge() + self._lock = threading.RLock() + self._lifecycle_lock = threading.RLock() + self._records = self._read() + self._registered: dict[str, list[str]] = {} + self._summaries: dict[str, list[McpToolSummary]] = {} + self._last_status: dict[str, dict[str, Any]] = {} + self._generations: dict[str, object] = {} + self._migrate_environment_secrets() + + def list(self) -> list[McpServer]: + with self._lock: + return [ + self._public(server_id, record) + for server_id, record in self._records.items() + ] + + def get(self, server_id: str) -> McpServer: + with self._lock: + return self._public(server_id, self._record(server_id)) + + def list_tools(self, server_id: str) -> list[McpToolSummary]: + self._record(server_id) + return [ + item.model_copy(deep=True) for item in self._summaries.get(server_id, []) + ] + + @_serialized_lifecycle + def create(self, request: McpServerCreateRequest) -> McpServer: + self._validate(request) + with self._lock: + if len(self._records) >= _MAX_MCP_SERVERS: + raise McpRegistryError( + "MCP_SERVER_LIMIT_REACHED", + f"At most {_MAX_MCP_SERVERS} MCP servers can be configured.", + status_code=409, + ) + server_id = uuid4().hex[:12] + record = request.model_dump(mode="json") + record["name"] = request.name.strip() + record["command"] = request.command.strip() if request.command else None + record["url"] = request.url.strip() if request.url else None + record.update( + version=1, + secret_environment_version=2, + enabled=False, + approved_digest=None, + tested_digest=None, + last_tested_at=None, + last_test_succeeded=None, + ) + with self._lock: + updated = {**self._records, server_id: record} + self._write(updated) + self._records = updated + return self.get(server_id) + + @_serialized_lifecycle + def update(self, server_id: str, request: McpServerUpdateRequest) -> McpServer: + self._validate(request) + current = self._record(server_id) + if request.version != current.get("version", 1): + raise McpRegistryError( + "MCP_SERVER_VERSION_CONFLICT", + "MCP server configuration version is stale.", + status_code=409, + ) + self.disable(server_id) + with self._lock: + previous = self._record(server_id) + removed_secret_ids = [ + secret_id + for kind, old_keys, new_keys in ( + ( + "environment", + previous.get("secret_environment_keys", []), + request.secret_environment_keys, + ), + ( + "header", + previous.get("secret_header_keys", []), + request.secret_header_keys, + ), + ) + for secret_id in self._secret_ids(server_id, old_keys, kind) + - self._secret_ids(server_id, new_keys, kind) + ] + try: + self.credentials.delete_many(removed_secret_ids) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + with self._lock: + record = request.model_dump(mode="json", exclude={"version"}) + record["name"] = request.name.strip() + record["command"] = request.command.strip() if request.command else None + record["url"] = request.url.strip() if request.url else None + record.update( + version=request.version + 1, + secret_environment_version=2, + enabled=False, + approved_digest=None, + tested_digest=None, + last_tested_at=None, + last_test_succeeded=None, + ) + updated = {**self._records, server_id: record} + self._write(updated) + self._records = updated + self._last_status.pop(server_id, None) + self._summaries.pop(server_id, None) + return self.get(server_id) + + @_serialized_lifecycle + def delete(self, server_id: str) -> None: + self.disable(server_id) + with self._lock: + record = self._record(server_id) + secret_ids = [ + secret_id + for kind, keys in ( + ("environment", record.get("secret_environment_keys", [])), + ("header", record.get("secret_header_keys", [])), + ) + for secret_id in self._secret_ids(server_id, keys, kind) + ] + try: + self.credentials.delete_many(secret_ids) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + with self._lock: + updated = dict(self._records) + del updated[server_id] + self._write(updated) + self._records = updated + self._last_status.pop(server_id, None) + self._summaries.pop(server_id, None) + self.bridge.remove(self._host_id(server_id)) + + @_serialized_lifecycle + def trust(self, server_id: str, command_digest: str) -> McpServer: + with self._lock: + record = self._record(server_id) + current = self._digest(record) + if command_digest != current: + raise McpRegistryError( + "MCP_TRUST_DIGEST_STALE", + "MCP server configuration changed; review it again.", + status_code=409, + ) + approved = {**record, "approved_digest": current} + updated = {**self._records, server_id: approved} + self._write(updated) + self._records = updated + return self.get(server_id) + + @_serialized_lifecycle + def put_secret( + self, server_id: str, key: str, secret: str, *, kind: str = "environment" + ) -> McpServerSecretStatus: + with self._lock: + record = self._record(server_id) + declared = self._secret_keys(record, kind) + self._validate_secret_key(key, kind) + if key not in declared: + raise McpRegistryError( + "MCP_SECRET_NOT_DECLARED", + "Secret environment key is not declared in this server configuration.", + ) + if record.get("enabled"): + self.disable(server_id) + self._invalidate_test(server_id) + try: + self.credentials.put(self._secret_id(server_id, key, kind), secret) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + return McpServerSecretStatus(key=key, configured=True) + + @_serialized_lifecycle + def delete_secret( + self, server_id: str, key: str, *, kind: str = "environment" + ) -> McpServerSecretStatus: + record = self._record(server_id) + if key not in self._secret_keys(record, kind): + raise McpRegistryError( + "MCP_SECRET_NOT_DECLARED", + "Secret environment key is not declared in this server configuration.", + ) + if record.get("enabled"): + self.disable(server_id) + self._invalidate_test(server_id) + try: + self.credentials.delete(self._secret_id(server_id, key, kind)) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + return McpServerSecretStatus(key=key, configured=False) + + @_serialized_lifecycle + def test(self, server_id: str) -> McpServer: + record = self._record(server_id) + if record.get("enabled"): + raise McpRegistryError( + "MCP_SERVER_ALREADY_ENABLED", + "Disable the MCP server before running an isolated connection test.", + status_code=409, + ) + self._require_launch_allowed(record, require_test=False) + try: + discovered = self._start(server_id, record) + except Exception as exc: + self._generations.pop(server_id, None) + self.bridge.remove(self._host_id(server_id)) + tested_at = datetime.now(UTC) + failure = { + "status": PluginHostState.error, + "error": str(exc), + "last_tested_at": tested_at, + "last_test_succeeded": False, + } + self._last_status[server_id] = failure + with self._lock: + failed_record = { + **record, + "tested_digest": None, + "last_tested_at": tested_at.isoformat(), + "last_test_succeeded": False, + } + updated = {**self._records, server_id: failed_record} + self._write(updated) + self._records = updated + raise + status = self.bridge.status(self._host_id(server_id), self._backend(record)) + tested_at = datetime.now(UTC) + self._last_status[server_id] = { + "status": PluginHostState.stopped, + "tools_count": len(discovered), + "protocol_version": status.protocol_version, + "remote_server_name": status.server_name, + "remote_server_version": status.server_version, + "error": None, + "last_tested_at": tested_at, + "last_test_succeeded": True, + } + self._summaries[server_id] = self._tool_summaries(discovered) + self._generations.pop(server_id, None) + self.bridge.stop(self._host_id(server_id)) + with self._lock: + tested_record = { + **record, + "tested_digest": self._digest(record), + "last_tested_at": tested_at.isoformat(), + "last_test_succeeded": True, + } + updated = {**self._records, server_id: tested_record} + self._write(updated) + self._records = updated + return self.get(server_id) + + @_serialized_lifecycle + def enable(self, server_id: str) -> McpServer: + record = self._record(server_id) + if server_id in self._registered: + return self.get(server_id) + self._require_launch_allowed(record, require_test=True) + discovered = self._start(server_id, record) + self._summaries[server_id] = self._tool_summaries(discovered) + registered: list[str] = [] + try: + for item in discovered: + self._register(server_id, item) + registered.append(item.definition.name) + except Exception: + for name in registered: + self.tools.unregister(name) + self._generations.pop(server_id, None) + self.bridge.stop(self._host_id(server_id)) + raise + try: + with self._lock: + enabled_record = {**record, "enabled": True} + updated = {**self._records, server_id: enabled_record} + self._write(updated) + self._records = updated + self._registered[server_id] = registered + except McpRegistryError: + for name in registered: + self.tools.unregister(name) + self._generations.pop(server_id, None) + self.bridge.stop(self._host_id(server_id)) + raise + return self.get(server_id) + + @_serialized_lifecycle + def disable(self, server_id: str) -> McpServer: + with self._lock: + record = self._record(server_id) + disabled_record = {**record, "enabled": False} + updated = {**self._records, server_id: disabled_record} + self._write(updated) + self._records = updated + for name in self._registered.pop(server_id, []): + self.tools.unregister(name) + self._generations.pop(server_id, None) + self.bridge.stop(self._host_id(server_id)) + return self.get(server_id) + + @_serialized_lifecycle + def restore_enabled(self) -> None: + if not self._records: + return + for server_id, record in list(self._records.items()): + if record.get("enabled"): + try: + self.enable(server_id) + except (McpRegistryError, ValueError, OSError) as exc: + self._records[server_id] = {**record, "enabled": False} + self._last_status[server_id] = { + "status": PluginHostState.error, + "error": str(exc), + } + self._write() + + @_serialized_lifecycle + def shutdown(self) -> None: + for server_id in list(self._records): + self._generations.pop(server_id, None) + for name in self._registered.pop(server_id, []): + self.tools.unregister(name) + self.bridge.stop(self._host_id(server_id)) + + def _start(self, server_id: str, record: dict[str, Any]) -> list[McpDiscoveredTool]: + environment = dict(record.get("environment", {})) + for key in record.get("secret_environment_keys", []): + try: + value = self.credentials.resolve( + self._secret_id(server_id, key, "environment") + ) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + if value is None: + raise McpRegistryError( + "MCP_SECRET_REQUIRED", + f"Secret environment variable is not configured: {key}", + status_code=409, + ) + environment[key] = value + headers = dict(record.get("headers", {})) + for key in record.get("secret_header_keys", []): + try: + value = self.credentials.resolve( + self._secret_id(server_id, key, "header") + ) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + if value is None: + raise McpRegistryError( + "MCP_SECRET_REQUIRED", + f"Secret HTTP header is not configured: {key}", + status_code=409, + ) + headers[key] = value + host_id = self._host_id(server_id) + # A queued callback from the previous process must not affect its replacement. + generation = object() + self._generations[server_id] = generation + self.bridge.remove(host_id) + try: + return self.bridge.start( + host_id, + self._backend(record), + self._server_dir(server_id), + list(record.get("permissions", [])), + lambda _host, message: self._unavailable( + server_id, generation, message + ), + command_override=( + [record["command"], *record.get("args", [])] + if record.get("command") + else None + ), + environment=environment, + tool_source="mcp_server", + transport_kind=record["transport"], + url=record.get("url"), + headers=headers, + ) + except McpBridgeError as exc: + self._generations.pop(server_id, None) + self.bridge.remove(host_id) + raise McpRegistryError( + exc.code, exc.message, status_code=exc.status_code + ) from exc + + def _register(self, server_id: str, discovered: McpDiscoveredTool) -> None: + definition = discovered.definition + model_name = "McpArgs_" + re.sub(r"\W+", "_", definition.name) + arguments_model = create_model(model_name, __config__=ConfigDict(extra="allow")) + + async def executor(arguments: BaseModel, context: ToolExecutionContext) -> Any: + return await self.bridge.call_tool( + self._host_id(server_id), + discovered.remote_name, + arguments.model_dump(exclude_unset=True), + request_id=context.tool_call_id + or f"{context.run_id}:{definition.name}", + ) + + self.tools.register(definition, arguments_model, executor) + + def _unavailable(self, server_id: str, generation: object, message: str) -> None: + # A failure may race with enable(). Waiting for the lifecycle mutation makes + # sure tools registered immediately before the callback are also removed. + with self._lifecycle_lock: + if self._generations.get(server_id) is not generation: + return + self._generations.pop(server_id, None) + try: + with self._lock: + record = self._records.get(server_id) + registered = self._registered.pop(server_id, []) + for name in registered: + self.tools.unregister(name) + if record is not None and (record.get("enabled") or registered): + self._records[server_id] = {**record, "enabled": False} + self._last_status[server_id] = { + "status": PluginHostState.unhealthy, + "error": message, + } + self._write() + finally: + # broken() can run on the client's reader/event thread. stop() does + # not join that thread, and setting _stopping before closing the + # transport prevents the close itself from reporting another failure. + self.bridge.remove(self._host_id(server_id)) + + def _require_launch_allowed( + self, record: dict[str, Any], *, require_test: bool + ) -> None: + digest = self._digest(record) + if ( + record.get("transport") == McpServerTransport.stdio.value + and not self.allow_process_launch + ): + raise McpRegistryError( + "MCP_SANDBOX_REQUIRED", + "Python process launch is disabled outside development until the desktop sandbox is available.", + status_code=403, + ) + if record.get("approved_digest") != digest: + raise McpRegistryError( + "MCP_TRUST_APPROVAL_REQUIRED", + "Review and approve the current MCP connection before testing or enabling it.", + status_code=409, + ) + if require_test and record.get("tested_digest") != digest: + raise McpRegistryError( + "MCP_CONNECTION_TEST_REQUIRED", + "Test the current MCP configuration successfully before enabling it.", + status_code=409, + ) + + def _public(self, server_id: str, record: dict[str, Any]) -> McpServer: + digest = self._digest(record) + backend = self._backend(record) + status = self.bridge.status(self._host_id(server_id), backend) + cached = self._last_status.get(server_id, {}) + return McpServer( + server_id=server_id, + version=record.get("version", 1), + name=record["name"], + transport=record["transport"], + command=record.get("command"), + args=list(record.get("args", [])), + url=record.get("url"), + headers=dict(record.get("headers", {})), + environment=dict(record.get("environment", {})), + secret_environment={ + key: self._secret_configured(server_id, key) + for key in record.get("secret_environment_keys", []) + }, + secret_headers={ + key: self._secret_configured(server_id, key, "header") + for key in record.get("secret_header_keys", []) + }, + permissions=list(record.get("permissions", [])), + startup_timeout_seconds=backend.startup_timeout_seconds, + tool_timeout_seconds=backend.tool_timeout_seconds, + enabled=bool(record.get("enabled")), + trusted=record.get("approved_digest") == digest, + command_digest=digest, + command_summary=self._summary(record), + status=status.status + if record.get("enabled") + else cached.get("status", PluginHostState.stopped), + tools_count=status.tools_count + if record.get("enabled") + else cached.get("tools_count", 0), + protocol_version=status.protocol_version + if record.get("enabled") + else cached.get("protocol_version"), + remote_server_name=status.server_name + if record.get("enabled") + else cached.get("remote_server_name"), + remote_server_version=status.server_version + if record.get("enabled") + else cached.get("remote_server_version"), + error=status.error if record.get("enabled") else cached.get("error"), + last_tested_at=record.get("last_tested_at") or cached.get("last_tested_at"), + last_test_succeeded=record.get("last_test_succeeded") + if record.get("last_test_succeeded") is not None + else cached.get("last_test_succeeded"), + ) + + def _validate(self, request: McpServerCreateRequest) -> None: + if not request.name.strip(): + raise McpRegistryError( + "MCP_SERVER_NAME_INVALID", "MCP server name cannot be blank." + ) + if request.transport == McpServerTransport.stdio: + if ( + not request.command + or not request.command.strip() + or "\x00" in request.command + ): + raise McpRegistryError( + "MCP_COMMAND_INVALID", "MCP executable is invalid." + ) + if request.url or request.headers or request.secret_header_keys: + raise McpRegistryError( + "MCP_CONFIG_INVALID", + "stdio configuration cannot contain HTTP fields.", + ) + if any("\x00" in arg for arg in request.args): + raise McpRegistryError( + "MCP_COMMAND_INVALID", "MCP argument contains a null byte." + ) + else: + self._validate_http_url(request.url) + if ( + request.command + or request.args + or request.environment + or request.secret_environment_keys + ): + raise McpRegistryError( + "MCP_CONFIG_INVALID", + "HTTP configuration cannot contain stdio fields.", + ) + for key in [*request.environment, *request.secret_environment_keys]: + self._validate_environment_key(key) + if set(request.environment) & set(request.secret_environment_keys): + raise McpRegistryError( + "MCP_ENVIRONMENT_INVALID", + "An environment key cannot be both plain and secret.", + ) + plain_headers = {key.casefold() for key in request.headers} + secret_headers = {key.casefold() for key in request.secret_header_keys} + for key in [*request.headers, *request.secret_header_keys]: + self._validate_header_key(key) + if any( + "\r" in value or "\n" in value or "\x00" in value + for value in request.headers.values() + ): + raise McpRegistryError( + "MCP_HEADER_INVALID", "HTTP header value contains control characters." + ) + if plain_headers & secret_headers: + raise McpRegistryError( + "MCP_HEADER_INVALID", "An HTTP header cannot be both plain and secret." + ) + unknown_permissions = set(request.permissions) - KNOWN_PERMISSIONS + if unknown_permissions: + raise McpRegistryError( + "MCP_PERMISSION_INVALID", + f"Unknown MCP permission: {min(unknown_permissions)}", + ) + + @staticmethod + def _validate_environment_key(key: str) -> None: + if not _ENVIRONMENT_KEY.fullmatch(key): + raise McpRegistryError( + "MCP_ENVIRONMENT_INVALID", f"Invalid environment variable name: {key}" + ) + + @staticmethod + def _validate_header_key(key: str) -> None: + if not _HEADER_KEY.fullmatch(key) or key.casefold() in _RESERVED_HEADERS: + raise McpRegistryError( + "MCP_HEADER_INVALID", f"Invalid or reserved HTTP header: {key}" + ) + + @staticmethod + def _validate_http_url(url: str | None) -> None: + if not url: + raise McpRegistryError("MCP_URL_INVALID", "MCP HTTP URL is required.") + parts = urlsplit(url.strip()) + if ( + parts.scheme not in {"http", "https"} + or not parts.hostname + or parts.username is not None + or parts.password is not None + or parts.fragment + ): + raise McpRegistryError( + "MCP_URL_INVALID", + "MCP URL must be an HTTP(S) URL without credentials or fragments.", + ) + + @staticmethod + def _backend(record: dict[str, Any]) -> PluginBackend: + return _McpConnectionBackend( + type="mcp", + transport="stdio", + command=record.get("command") or "http", + args=record.get("args", []), + startup_timeout_seconds=record.get("startup_timeout_seconds", 15), + tool_timeout_seconds=record.get("tool_timeout_seconds", 30), + ) + + @staticmethod + def _host_id(server_id: str) -> str: + return f"mcp.{server_id}" + + def _server_dir(self, server_id: str) -> Path: + path = self.data_dir / "mcp" / "workdirs" / server_id + path.mkdir(parents=True, exist_ok=True) + return path + + @staticmethod + def _digest(record: dict[str, Any]) -> str: + executable = { + key: record.get(key) + for key in ( + "transport", + "command", + "args", + "environment", + "secret_environment_keys", + "url", + "headers", + "secret_header_keys", + "permissions", + ) + } + return hashlib.sha256( + json.dumps( + executable, sort_keys=True, ensure_ascii=False, separators=(",", ":") + ).encode() + ).hexdigest() + + @staticmethod + def _summary(record: dict[str, Any]) -> str: + if record.get("transport") != McpServerTransport.stdio.value: + header_names = sorted( + [*record.get("headers", {}), *record.get("secret_header_keys", [])], + key=str.casefold, + ) + suffix = f" headers={','.join(header_names)}" if header_names else "" + return f"{record.get('transport')} {record.get('url') or ''}{suffix}" + return " ".join( + [ + record.get("command") or "", + *[ + json.dumps(arg, ensure_ascii=False) + for arg in record.get("args", []) + ], + ] + ) + + @staticmethod + def _secret_id(server_id: str, key: str, kind: str = "environment") -> str: + identity = ( + f"environment-v2\0{key}" + if kind == "environment" + else f"{kind}\0{key.casefold()}" + ) + suffix = hashlib.sha256(identity.encode()).hexdigest()[:20] + return f"mcp.{server_id}.{suffix}" + + @staticmethod + def _legacy_environment_secret_id(server_id: str, key: str) -> str: + suffix = hashlib.sha256(f"environment\0{key.casefold()}".encode()).hexdigest()[ + :20 + ] + return f"mcp.{server_id}.{suffix}" + + def _secret_ids(self, server_id: str, keys: list[str], kind: str) -> set[str]: + ids = {self._secret_id(server_id, key, kind) for key in keys} + if kind == "environment": + # Include retained ambiguous legacy ciphertext when its last declaration is removed. + ids.update( + self._legacy_environment_secret_id(server_id, key) for key in keys + ) + return ids + + def _migrate_environment_secrets(self) -> None: + """迁移旧的大小写折叠 ID;已碰撞的值无法恢复,保留原密文并要求重新录入。""" + + replacements: dict[str, str] = {} + ambiguous: dict[str, list[str]] = {} + legacy_records = { + server_id: record + for server_id, record in self._records.items() + if record.get("secret_environment_version", 1) == 1 + } + if not legacy_records: + return + for server_id, record in legacy_records.items(): + groups: dict[str, set[str]] = {} + for key in record.get("secret_environment_keys", []): + groups.setdefault(key.casefold(), set()).add(key) + for keys in groups.values(): + key = next(iter(keys)) + legacy_id = self._legacy_environment_secret_id(server_id, key) + if len(keys) == 1: + replacements[legacy_id] = self._secret_id(server_id, key) + else: + ambiguous.setdefault(server_id, []).extend(keys) + try: + self.credentials.move_many(replacements) + for server_id, keys in ambiguous.items(): + if not any( + self.credentials.has( + self._legacy_environment_secret_id(server_id, key) + ) + for key in keys + ): + continue + if all( + self.credentials.has(self._secret_id(server_id, key)) + for key in keys + ): + continue + self._records[server_id].update( + enabled=False, + tested_digest=None, + last_test_succeeded=None, + last_tested_at=None, + ) + self._last_status[server_id] = { + "status": PluginHostState.error, + "error": "环境变量密钥名称曾发生大小写冲突,请分别重新录入密钥并测试连接。", + } + # Persist a migration marker even when legacy values were ambiguous. + # Otherwise a later key removal could make that old shared value look + # unambiguous and resurrect a deleted credential on the next restart. + for server_id in legacy_records: + self._records[server_id]["secret_environment_version"] = 2 + self._write() + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + + def _secret_configured( + self, server_id: str, key: str, kind: str = "environment" + ) -> bool: + try: + return self.credentials.has(self._secret_id(server_id, key, kind)) + except CredentialStoreError as exc: + raise McpRegistryError( + "MCP_SECRET_STORE_ERROR", str(exc), status_code=500 + ) from exc + + @staticmethod + def _secret_keys(record: dict[str, Any], kind: str) -> list[str]: + if kind == "environment": + return list(record.get("secret_environment_keys", [])) + if kind == "header": + return list(record.get("secret_header_keys", [])) + raise McpRegistryError("MCP_SECRET_KIND_INVALID", "Unknown MCP secret kind.") + + @staticmethod + def _validate_secret_key(key: str, kind: str) -> None: + if kind == "environment": + McpServerRegistry._validate_environment_key(key) + elif kind == "header": + McpServerRegistry._validate_header_key(key) + else: + raise McpRegistryError( + "MCP_SECRET_KIND_INVALID", "Unknown MCP secret kind." + ) + + @staticmethod + def _tool_summaries(discovered: list[McpDiscoveredTool]) -> list[McpToolSummary]: + return [ + McpToolSummary( + name=item.definition.name, + remote_name=item.remote_name, + description=item.definition.description, + permission=item.definition.permission, + ) + for item in discovered + ] + + def _record(self, server_id: str) -> dict[str, Any]: + try: + return self._records[server_id] + except KeyError as exc: + raise McpRegistryError( + "MCP_SERVER_NOT_FOUND", + "MCP server configuration was not found.", + status_code=404, + ) from exc + + def _invalidate_test(self, server_id: str) -> None: + """Make credential changes safe before touching the encrypted store.""" + + with self._lock: + record = self._record(server_id) + invalidated = { + **record, + "tested_digest": None, + "last_tested_at": None, + "last_test_succeeded": None, + } + updated = {**self._records, server_id: invalidated} + self._write(updated) + self._records = updated + self._last_status.pop(server_id, None) + self._summaries.pop(server_id, None) + + @property + def _path(self) -> Path: + return self.data_dir / "mcp" / "servers.json" + + def _read(self) -> dict[str, dict[str, Any]]: + if not self._path.exists(): + return {} + try: + value = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise McpRegistryError( + "MCP_REGISTRY_INVALID", + "MCP server registry cannot be loaded.", + status_code=500, + ) from exc + if not isinstance(value, dict): + raise McpRegistryError( + "MCP_REGISTRY_INVALID", + "MCP server registry has an invalid format.", + status_code=500, + ) + if len(value) > _MAX_MCP_SERVERS: + raise McpRegistryError( + "MCP_REGISTRY_INVALID", + "MCP server registry contains too many records.", + status_code=500, + ) + normalized: dict[str, dict[str, Any]] = {} + config_fields = set(McpServerConfig.model_fields) + try: + for server_id, raw in value.items(): + if not isinstance(server_id, str) or not _SERVER_ID.fullmatch( + server_id + ): + raise ValueError("invalid server id") + record = _McpServerRecord.model_validate(raw) + config = record.model_dump(mode="json", include=config_fields) + self._validate(McpServerCreateRequest.model_validate(config)) + normalized[server_id] = record.model_dump(mode="json") + except (McpRegistryError, ValidationError, ValueError, TypeError) as exc: + raise McpRegistryError( + "MCP_REGISTRY_INVALID", + "MCP server registry contains an invalid record.", + status_code=500, + ) from exc + return normalized + + def _write(self, records: dict[str, dict[str, Any]] | None = None) -> None: + temporary = self._path.with_suffix(".tmp") + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + temporary.write_text( + json.dumps( + records if records is not None else self._records, + ensure_ascii=False, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + temporary.replace(self._path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise McpRegistryError( + "MCP_REGISTRY_WRITE_FAILED", + "MCP server registry cannot be written.", + status_code=500, + ) from exc diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 832b726..960554e 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index 8a3aae5..bfaaf91 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ async def lifespan(_: FastAPI): yield # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 container.plugins.shutdown() + container.mcp_servers.shutdown() app = FastAPI( diff --git a/backend/app/providers/credentials.py b/backend/app/providers/credentials.py index 19e83c3..8eec9a2 100644 --- a/backend/app/providers/credentials.py +++ b/backend/app/providers/credentials.py @@ -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) diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 38d1687..84f08a7 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -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 { diff --git a/backend/app/routes.py b/backend/app/routes.py index 0c14ce2..fbe636e 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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 diff --git a/backend/app/schema_security.py b/backend/app/schema_security.py new file mode 100644 index 0000000..791fa62 --- /dev/null +++ b/backend/app/schema_security.py @@ -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), + ) diff --git a/backend/extensions/fixtures/mcp-echo/commands.yaml b/backend/extensions/fixtures/mcp-echo/commands.yaml new file mode 100644 index 0000000..0428b86 --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/commands.yaml @@ -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 diff --git a/backend/extensions/fixtures/mcp-echo/plugin.yaml b/backend/extensions/fixtures/mcp-echo/plugin.yaml index 34c4312..06fc59e 100644 --- a/backend/extensions/fixtures/mcp-echo/plugin.yaml +++ b/backend/extensions/fixtures/mcp-echo/plugin.yaml @@ -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 diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py index b1fefda..fed5e55 100644 --- a/backend/extensions/fixtures/mcp-echo/server.py +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -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( diff --git a/backend/extensions/fixtures/mcp-echo/settings.yaml b/backend/extensions/fixtures/mcp-echo/settings.yaml new file mode 100644 index 0000000..3ddb40e --- /dev/null +++ b/backend/extensions/fixtures/mcp-echo/settings.yaml @@ -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 diff --git a/backend/extensions/plugins/text-tools/commands.yaml b/backend/extensions/plugins/text-tools/commands.yaml new file mode 100644 index 0000000..611b516 --- /dev/null +++ b/backend/extensions/plugins/text-tools/commands.yaml @@ -0,0 +1,19 @@ +commands: + - command_id: text-tools.uppercase-selection + title: 转为大写 + description: 将当前选区或传入文本转换为大写并显示通知。 + icon: edit + locations: + - command_palette + - context_menu + when: + - editor.has_selection + context: + - selection + handler: uppercase_selection + parameters: + type: object + properties: + text: + type: string + additionalProperties: false diff --git a/backend/extensions/plugins/text-tools/plugin.yaml b/backend/extensions/plugins/text-tools/plugin.yaml index 4f70c0f..272d05f 100644 --- a/backend/extensions/plugins/text-tools/plugin.yaml +++ b/backend/extensions/plugins/text-tools/plugin.yaml @@ -6,6 +6,10 @@ permissions: [] contributes: tools: - text.uppercase + commands: + - text-tools.uppercase-selection + settings_sections: + - text-tools.general backend: type: internal_rpc transport: none diff --git a/backend/extensions/plugins/text-tools/settings.yaml b/backend/extensions/plugins/text-tools/settings.yaml new file mode 100644 index 0000000..171ef27 --- /dev/null +++ b/backend/extensions/plugins/text-tools/settings.yaml @@ -0,0 +1,31 @@ +section_id: text-tools.general +schema_version: 1 +fields: + - key: result_limit + label: 结果字符数 + description: Command 通知中最多保留的字符数。 + type: number + required: true + default: 100 + minimum: 1 + maximum: 1000 + - key: label_prefix + label: 标签前缀 + type: string + default: "" + - key: output_style + label: 输出样式 + type: select + default: notification + options: + - notification + - compact + - key: enabled_hint + label: 显示提示 + type: boolean + default: true + - key: api_key + label: API Key + description: Secret 示例字段;普通 Settings API 永不返回明文。 + type: secret + required: false diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3688aaa..6c3d4c0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", ] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e76a1e8..97d7fee 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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}", diff --git a/backend/tests/test_credentials.py b/backend/tests/test_credentials.py index d985827..ca09176 100644 --- a/backend/tests/test_credentials.py +++ b/backend/tests/test_credentials.py @@ -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") diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 29a2d92..bdffc8c 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -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()) diff --git a/backend/tests/test_mcp_registry.py b/backend/tests/test_mcp_registry.py new file mode 100644 index 0000000..2d141c0 --- /dev/null +++ b/backend/tests/test_mcp_registry.py @@ -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() diff --git a/backend/tests/test_mcp_sse_limits.py b/backend/tests/test_mcp_sse_limits.py new file mode 100644 index 0000000..6c0a091 --- /dev/null +++ b/backend/tests/test_mcp_sse_limits.py @@ -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")] diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py new file mode 100644 index 0000000..1235d6f --- /dev/null +++ b/backend/tests/test_plugin_contributions.py @@ -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) diff --git a/backend/tests/test_schema_security.py b/backend/tests/test_schema_security.py new file mode 100644 index 0000000..cac9412 --- /dev/null +++ b/backend/tests/test_schema_security.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock index 03432b5..16eefd2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] diff --git a/docs/README.md b/docs/README.md index cc1eed1..a6dda3b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,8 @@ - [Benchmark 开发说明](development/Benchmark开发说明.md) - [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md) - [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md) +- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md) +- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md) - [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md) - [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md) - [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md) @@ -49,6 +51,7 @@ - [后端全面审阅问题与修复复盘](retrospectives/后端全面审阅问题与修复复盘.md) - [Agent Core 第二阶段问题与修复复盘](retrospectives/Agent-Core第二阶段问题与修复复盘.md) - [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md) +- [Plugin Command 与 Settings 问题与修复复盘](retrospectives/Plugin-Command与Settings问题与修复复盘.md) - [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.md) ## 推荐阅读顺序 diff --git a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md index 4bea18f..4ca25d9 100644 --- a/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md +++ b/docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md @@ -5,7 +5,7 @@ > 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步 > 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员 -> 实施状态更新:2026-09-03。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已完成;RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、Plugin Command/Settings、Provider 协议增强、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 +> 实施状态更新:2026-09-03。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成;RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、Provider 协议增强、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。 --- @@ -1050,7 +1050,7 @@ Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --fro MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。 -当前已实现本地 stdio 首版:Plugin Runtime 在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list`、`tools/call`、取消、超时、异常退出和 Host Restart。实现接受 `2025-11-25`、`2025-06-18`、`2025-03-26` 与 `2024-11-05` 协议版本;Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。 +当前 Plugin Runtime 已实现本地 stdio Host:在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list`、`tools/call`、取消、超时、异常退出和 Host Restart。独立 MCP Server Registry 另行支持 stdio、Streamable HTTP 与旧 HTTP+SSE 兼容,包括 Session、协议 Header、认证 Header Secret、测试门禁和 Tool 动态映射。实现接受 `2025-11-25`、`2025-06-18`、`2025-03-26` 与 `2024-11-05` 协议版本;Plugin Manifest 的 Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。 MCP Tool 进入系统后的调用路径为: @@ -2333,7 +2333,7 @@ Markdown Workspace 第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。 -截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 +截至 2026-09-03,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口、stdio MCP Bridge / Plugin Host,以及 Plugin Command/Settings 前后端闭环也已完成。Plugin 详情页现已提供 Host 状态、重启、动态设置、Secret 管理和命令执行,全局命令面板可加载 Plugin Command。当前验证基线为后端 136 项测试、前端 32 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。 第二阶段在既有 Contract 上接入: @@ -2365,7 +2365,7 @@ Frontend Extension └── Plugin Settings UI ``` -上述列表描述第二阶段技术范围,其中 stdio MCP Bridge 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。 +上述列表描述第二阶段技术范围,其中 stdio MCP Bridge、Plugin Command Contribution 和 Plugin Settings Contribution 后端 Contract 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。 第三阶段处理: @@ -2419,9 +2419,9 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启 目标桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 已管理笔记元数据、全文索引、向量索引、任务及 Agent Trace;Provider/Extension Registry 当前仍为内存实现。 -Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 在第二阶段接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并将扩展 Agent Trace Contract 供可视化和 Benchmark 共用;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。 +Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 当前通过 FTS5、`HashEmbeddingProvider`、sqlite-vec、RRF 和轻量 Reranker 跑通混合检索,真实 Embedding 与正式 Benchmark 仍待第二阶段后续接入;Agent Runtime 使用 Tool Registry 操作知识库和任务,并已持久化可供前端可视化与 Benchmark 共用的 Agent Trace Contract;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置。 -当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution,并已通过 stdio MCP Bridge 接入独立进程 Tool、Host 状态与重启接口;Command 与 Settings Contribution 尚待后续阶段实现。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,第二阶段按统一行为测试完善 OpenAI Responses、Anthropic Messages 等协议。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。 +当前 Plugin Runtime 支持 Manifest、生命周期、声明式白名单 Tool Contribution、Plugin Command 与 Plugin Settings/Secret,并已通过 stdio MCP Bridge 接入独立进程 Tool、专用 MCP Command Target、Host 状态与重启接口。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 Ollama,OpenAI Responses、Anthropic Messages 等协议仍待第二阶段后续完善。多模态目标方案使用 faster-whisper、pyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。 第二阶段内容输出以 Document AST、Exporter Adapter、Mermaid Renderer 和 Function Plot Renderer 为共同边界,支持 HTML、PDF、DOCX 与静态图导出。Theme Package 使用 Manifest、Design Token 和受限 CSS 实现本地导入;联网主题市场不属于本阶段核心依赖。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server。 diff --git a/docs/architecture/第一阶段分工表.md b/docs/architecture/第一阶段分工表.md index 9a9a2a5..972cbf3 100644 --- a/docs/architecture/第一阶段分工表.md +++ b/docs/architecture/第一阶段分工表.md @@ -1,6 +1,6 @@ # 第一阶段分工表 -> 状态更新:2026-08-30。本文保留原始职责划分,同时记录当前交付状态。第一阶段后端目标已完成,前端 Web 联调页面已完成;尚未纳入本阶段完成项的是 Tauri Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。 +> 状态更新:2026-09-02。本文保留第一阶段原始职责划分和交付口径。第一阶段后端目标与前端 Web 联调页面均已完成;第二阶段此后又完成 Agent Trace 持久化与 SSE 恢复、独立 stdio MCP Plugin Host 和 Plugin Command/Settings 后端 Contract。Tauri Host、Stronghold、原生多 Vault 文件系统、真实音频模型和 Sync Server 仍未实现。 ## 当前交付状态 diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md index 7fafabf..3ae6cc9 100644 --- a/docs/architecture/第二阶段团队分工表.md +++ b/docs/architecture/第二阶段团队分工表.md @@ -6,9 +6,9 @@ 第二阶段继续保持第一阶段的模块 ownership: -- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。 +- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、Plugin Command / Settings UI、整体架构与代码审阅。 - 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。 -- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。 +- 吉海燕:Frontend、Theme、Agent Trace、Mermaid 渲染。 --- @@ -16,9 +16,9 @@ | 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 | | --- | --- | --- | --- | -| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 | +| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command / Settings 前后端闭环、更多 Provider、整体集成、代码审阅与统筹 | 为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 | | 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 | -| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 | +| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Mermaid 渲染支持 | 与范涵宇联调 AgentEvent;与杨星萱联调函数图像及导出预览 | --- @@ -384,7 +384,7 @@ Metadata Filter --- -## 五、吉海燕 +## 五、前端展示层(吉海燕;5.4—5.5 由范涵宇负责) ### 5.1 Theme Import @@ -488,7 +488,7 @@ Error - 用户取消; - Citation 跳转。 -### 5.4 Plugin Command 前端 +### 5.4 Plugin Command 前端(范涵宇) 负责 Command Contribution 在前端呈现。 @@ -502,7 +502,7 @@ Toolbar Action 前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。 -### 5.5 Plugin Settings 前端 +### 5.5 Plugin Settings 前端(范涵宇) 根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。 @@ -572,9 +572,9 @@ Mermaid 渲染需要与 Theme Design Token 联动。 | --- | --- | --- | | MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 | | Plugin Command Runtime | 范涵宇 | 吉海燕 | -| Plugin Command UI | 吉海燕 | 范涵宇 | +| Plugin Command UI | 范涵宇 | 吉海燕 | | Plugin Settings Runtime | 范涵宇 | 吉海燕 | -| Plugin Settings UI | 吉海燕 | 范涵宇 | +| Plugin Settings UI | 范涵宇 | 吉海燕 | | Agent Benchmark Framework | 杨星萱 | 范涵宇 | | Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 | | Agent Trace Visualization | 吉海燕 | 范涵宇 | @@ -748,8 +748,10 @@ Markdown - [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript; - [x] MCP Server 能通过 MCP Bridge 注册 Tool; - [x] Agent 能调用 MCP Tool; -- [ ] Plugin Command Contribution 后端可注册; -- [ ] Plugin Settings Contribution 后端可解析; +- [x] Plugin Command Contribution 后端可注册; +- [x] Plugin Settings Contribution 后端可解析; +- [x] Plugin Command 可以显示并从前端执行; +- [x] Plugin Settings 可以动态生成设置项并独立提交 Secret; - [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定; - [ ] 完成跨模块接口审阅和第二阶段集成。 @@ -773,8 +775,6 @@ Markdown - [ ] Theme 可以启用、停用和卸载; - [ ] Agent Trace 可以展示完整 Tool Call 顺序; - [ ] Trace Node 可以查看参数、结果、耗时和错误; -- [ ] Plugin Command 可以显示在前端; -- [ ] Plugin Settings 可以动态生成设置项; - [ ] Markdown Mermaid Code Block 可以渲染; - [ ] Mermaid 支持主题切换; - [ ] Mermaid 渲染错误可以明确展示; @@ -791,6 +791,8 @@ Markdown ├── MCP Bridge ├── Plugin Command Runtime ├── Plugin Settings Runtime +├── Plugin Command UI +├── Plugin Settings UI ├── Provider Adapter ├── Code Review └── Integration / Coordination @@ -812,8 +814,6 @@ Markdown ├── Theme Import ├── Theme Community Format ├── Agent Trace Visualization -├── Plugin Command UI -├── Plugin Settings UI └── Mermaid ├── Markdown Rendering ├── Theme Adaptation diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index 099a666..6cfa1f0 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -1,6 +1,6 @@ # 后端接口契约(开发版) -> 更新日期:2026-09-01。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。 +> 更新日期:2026-09-02。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。 ## 契约入口 @@ -176,7 +176,7 @@ RunCancelled ## 当前实现状态 -更新至 2026-09-01:后端 92 项回归测试通过。 +更新至 2026-09-02:后端 136 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节。 - Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。 - Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。 diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index a2d71e4..dfdf1be 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -2,7 +2,7 @@ > 文档状态:接口冻结草案 > -> 更新日期:2026-09-01 +> 更新日期:2026-09-03 > > 依据:`../architecture/第二阶段团队分工表.md`、`../architecture/AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md` @@ -27,7 +27,7 @@ | 标记 | 含义 | | --- | --- | -| 已实现 | 第一阶段接口已经存在,第二阶段保持兼容 | +| 已实现 | 接口已经落地并由当前 OpenAPI 与自动化测试覆盖 | | 扩展 | 路径已存在,第二阶段增加字段、事件或行为 | | 计划新增 | 第二阶段需要新增实现 | | 内部 Contract | 不直接暴露 HTTP,由两个模块共同遵守 | @@ -47,11 +47,18 @@ | Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 | | Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 | | Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool | -| Plugin Command | GET | `/api/plugin-contributions/commands` | 计划新增 | 获取前端可展示的 Command | -| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 计划新增 | 受控执行 Command | -| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 计划新增 | 获取 Schema 与非敏感配置 | -| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 计划新增 | 更新非敏感配置 | -| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 计划新增 | 写入或删除 Secret Reference | +| MCP Server | GET/POST | `/api/mcp/servers` | 已实现(C.1) | 列出、创建独立 MCP Server 配置 | +| MCP Server | GET/PUT/DELETE | `/api/mcp/servers/{server_id}` | 已实现(C.1) | 读取、版本化修改、删除独立配置 | +| MCP Server | GET | `/api/mcp/servers/{server_id}/tools` | 已实现(C.1) | 获取映射后的 Tool 摘要 | +| MCP Server | POST | `/api/mcp/servers/{server_id}/trust` | 已实现(C.1) | 确认当前连接配置摘要 | +| MCP Server | POST | `/api/mcp/servers/{server_id}/test` | 已实现(C.1) | 临时连接、握手、发现工具后关闭 | +| MCP Server | POST | `/api/mcp/servers/{server_id}/enable`、`disable` | 已实现(C.1) | 控制连接与动态 Tool 生命周期 | +| MCP Server | PUT/DELETE | `/api/mcp/servers/{server_id}/secrets/{key}` | 已实现(C.1) | 按 `kind` 写入或删除加密环境变量/Header | +| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command | +| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command | +| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 | +| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 已实现 | 更新非敏感配置 | +| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 已实现 | 写入或删除 Secret Reference | | Provider | 现有路径 | `/api/providers/*`、`POST /api/chat` | 扩展 | 补齐协议能力和统一行为 | | Retrieval | GET/POST | `/api/index/status`、`/api/index/rebuild` | 扩展 | 暴露 Embedding 兼容状态并安全重建向量 | | Benchmark | GET | `/api/benchmarks/datasets` | 计划新增 | 枚举受控 Dataset | @@ -540,7 +547,13 @@ error } ``` -允许的 effect 首批为 `none`、`notification`、`navigate`、`refresh` 和 `job`。前端仅执行白名单 effect;未知类型显示结果但不执行。 +允许的 effect 首批为 `none`、`notification`、`navigate`、`refresh` 和 `job`。每类 payload 也是契约的一部分:`none` 必须为空;`notification` 只接受 `level`(`info/success/warning/error`)和非空 `message`;`navigate` 只接受宿主路由名 `route`;`refresh` 只接受 `workspace/commands/settings/plugins` 范围;`job` 只接受受限格式的 `job_id`。后端拒绝未知字段和不匹配的 payload,前端仍须按判别联合穷尽处理,不得把 effect 当作任意代码执行。 + +当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。 + +需要 Secret 的 Command 必须在 `commands.yaml` 的内部 `secrets` 数组中声明对应 Setting Key,并在 Plugin Manifest 声明 `secrets.use` 权限。安装时宿主校验该字段确实属于当前 Plugin Settings Schema 的 `secret` 类型;只有权限已授予并启用后,运行时才向受控 handler 或 MCP Command Target 提供声明过的 Secret。未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置返回 `PLUGIN_SECRET_REQUIRED`。`secrets` 不属于前端 `PluginCommand` DTO,Secret 明文也不会并入普通 Settings 字典。 + +`commands.yaml` 中的执行目标必须在宿主白名单 `handler` 与当前 Plugin 命名空间的 `mcp_tool` 之间二选一。MCP Command Target 不注册为 Agent Tool;宿主用 `_notesagent` 保留包装传入 Command ID、参数、裁剪后的 Context、已校验的非敏感 Settings 和声明过的 Secret,并将 MCP structured result 再校验为白名单 effect。作为宿主协议标记,目标 MCP Tool 的 `inputSchema` 必须在顶层 `properties` 中直接声明 `_notesagent: { type: object }`;不得用顶层组合或引用替代该标记。`_notesagent` 对象内部仍可使用完整 Draft 2020-12 约束、文档内引用和组合 Schema。启用阶段只检查协议标记,不尝试求解 Schema 或伪造业务值;宿主会保留完整 Schema,并在每次调用前用官方 Validator 校验真实信封。Command 与 Tool Schema 仅允许 `#...` 文档内引用,任何通过 `$ref` 或 `$dynamicRef` 指向文件、HTTP 或其他外部资源的 Schema 都会在注册前被拒绝。文档内引用遵循 Draft 2020-12 的嵌套 `$id` 与 Anchor 资源作用域,不能解析的引用不得进入运行时。 ### 7.5 Settings Schema @@ -585,6 +598,8 @@ select secret ``` +Number 字段的 `minimum` 和 `maximum` 必须是有限数值;`NaN`、正无穷和负无穷均视为无效 Settings Schema。 + ### 7.6 更新 Settings 和 Secret `PUT /api/plugins/{plugin_id}/settings` @@ -596,7 +611,7 @@ secret } ``` -该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。 +该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。没有默认值的必填普通字段必须先通过该接口配置;否则 Plugin Enable 和 Command Execute 返回 `PLUGIN_SETTINGS_REQUIRED`,MCP 或内部 handler 不会收到残缺配置。 Secret 使用: @@ -623,6 +638,10 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret Secret 明文不进入普通 Settings、日志、Trace、Benchmark Dataset 或前端持久化。 +非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的定长 Secret Reference,格式为 `plugin.`;Secret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。 + +`plugin.*` 为宿主保留凭据命名空间。`/api/credentials/{credential_id}`、Provider 持久配置、Provider 临时测试凭据和 Provider Resolver 均拒绝该前缀,防止通过 Provider 链路覆盖、删除或向外部 Base URL 发送 Plugin Secret。 + ### 7.7 Plugin/MCP 错误码 ```text @@ -635,13 +654,66 @@ MCP_TOOL_SCHEMA_INVALID MCP_TOOL_CALL_FAILED MCP_TOOL_RESULT_TOO_LARGE MCP_TRUST_APPROVAL_REQUIRED +MCP_TRUST_DIGEST_STALE +MCP_SANDBOX_REQUIRED +MCP_TRANSPORT_UNSUPPORTED +MCP_SERVER_NOT_FOUND +MCP_SERVER_NAME_INVALID +MCP_SERVER_ALREADY_ENABLED +MCP_SERVER_VERSION_CONFLICT +MCP_SERVER_LIMIT_REACHED +MCP_REGISTRY_WRITE_FAILED +MCP_REGISTRY_INVALID +MCP_CONNECTION_TEST_REQUIRED +MCP_CONFIG_INVALID +MCP_COMMAND_INVALID +MCP_URL_INVALID +MCP_HEADER_INVALID +MCP_HTTP_REQUEST_FAILED +MCP_HTTP_RESPONSE_INVALID +MCP_SECRET_REQUIRED +MCP_SECRET_NOT_DECLARED +MCP_SECRET_KIND_INVALID +MCP_SECRET_STORE_ERROR +MCP_ENVIRONMENT_INVALID +MCP_PERMISSION_INVALID PLUGIN_COMMAND_NOT_FOUND +PLUGIN_COMMAND_CONFLICT +PLUGIN_COMMAND_INVALID +PLUGIN_COMMAND_ARGUMENT_INVALID PLUGIN_COMMAND_CONTEXT_INVALID +PLUGIN_COMMAND_TIMEOUT +PLUGIN_COMMAND_EXECUTION_FAILED +PLUGIN_COMMAND_RESULT_INVALID +PLUGIN_COMMAND_RESULT_TOO_LARGE +PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH PLUGIN_SETTINGS_SCHEMA_INVALID PLUGIN_SETTINGS_VERSION_CONFLICT +PLUGIN_SETTINGS_FIELD_INVALID +PLUGIN_SETTINGS_REQUIRED PLUGIN_SECRET_FIELD_NOT_FOUND +PLUGIN_SECRET_ACCESS_DENIED +PLUGIN_SECRET_REQUIRED +PLUGIN_SECRET_VALUE_INVALID +PLUGIN_SECRET_STORE_ERROR +PLUGIN_STORAGE_ERROR +CREDENTIAL_NAMESPACE_RESERVED ``` +### 7.8 独立 MCP Server Registry(C.1) + +独立 Server 不依附 Plugin Manifest,配置持久化于 `APP_DATA_DIR/mcp/servers.json`。`transport` 支持 `stdio`、`streamable_http` 和兼容旧服务的 `sse`。敏感环境变量与认证 Header 只以 `mcp.*` 引用进入加密凭据存储;读取响应以 `secret_environment`、`secret_headers` 的布尔值表示配置状态,不返回明文。动态工具使用 `mcp.{server_id}.{remote_tool}` 命名空间,来源标记为 `mcp_server`,仍通过统一 Tool Registry、Permission Manager 与 Agent Trace。 + +stdio 配置使用 `command`、`args`、`environment` 和 `secret_environment_keys`;HTTP/SSE 配置使用 `url`、`headers` 和 `secret_header_keys`,两组 Transport 字段不可混用。更新请求必须携带当前 `version`,成功后版本递增;过期版本返回 `409 MCP_SERVER_VERSION_CONFLICT`。`GET /tools` 返回 `name`、`remote_name`、`description` 和可选 `permission`。 + +创建或编辑配置后,调用方必须向 `/trust` 回传服务端计算的 `command_digest`。后端只接受与当前 Transport、连接参数、环境/Header 及权限完全一致的摘要;配置变化会撤销旧信任和测试结果。只有当前摘要通过 `/test`,才能调用 `/enable`。测试失败也会持久化时间和失败状态。 + +Secret 明文变化无法进入摘要,因此 Secret 写入和删除采用更严格规则:若 Server 已启用则先停用并注销 Tool,随后清除 `tested_digest` 和最近测试状态。调用方必须用新 Secret 再次执行 `/test`,不能沿用旧凭据的测试结果。 + +Streamable HTTP 支持 Session ID、`MCP-Protocol-Version`、JSON 或 SSE POST 响应、可选 GET 事件流及 `Last-Event-ID`;旧 SSE 按 endpoint 事件确定 POST 地址,并要求与初始 URL 同源。Secret 接口用 `?kind=environment` 或 `?kind=header` 区分类型。HTTP URL 不允许内嵌凭据或 Fragment,配置不得覆盖协议保留 Header。 + +stdio 命令始终以 executable 与 args 数组通过 `shell=False` 启动;普通环境变量和加密 Secret 显式注入,不继承 Provider Key、数据库或 Vault 路径。当前 Python Host 仅在 `APP_ENVIRONMENT=development` 时允许启动 stdio;其他环境返回 `403 MCP_SANDBOX_REQUIRED`。远程 HTTP Transport 不创建本机进程,但仍受摘要确认、成功测试、超时、消息限长与 Secret 隔离约束。 + --- ## 8. Provider Adapter 扩展 diff --git a/docs/development/AI-Core与Agent-Core开发说明.md b/docs/development/AI-Core与Agent-Core开发说明.md index fc79e05..dac8187 100644 --- a/docs/development/AI-Core与Agent-Core开发说明.md +++ b/docs/development/AI-Core与Agent-Core开发说明.md @@ -2,7 +2,7 @@ > 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。 -> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。 +> 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 136 项测试通过。 ## 当前实现 @@ -68,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider - Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供; - FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供; - 文件系统和 API Key 明文读取:由 Rust Host 提供; -- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。 +- Frontend Extension Slot 与 Plugin Command/Settings UI:后端 Contract 与前端 Service 已完成,页面由前端后续联调。 ## Provider @@ -347,6 +347,6 @@ Skill Manifest - Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。 - Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。 - Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。 -- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍按技术基线在第二阶段接入。 +- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍待第二阶段后续接入。 - Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。 -- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP Server;Streamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。 +- 当前 Plugin Host 支持内置声明式 handler、本地 stdio MCP Server 以及 Plugin Command/Settings;独立 MCP Server Registry 另行支持 stdio、Streamable HTTP 与旧 SSE 兼容。OS 级沙箱与 UI Contribution 留在后续阶段。 diff --git a/docs/development/Knowledge与Retrieval-Core开发说明.md b/docs/development/Knowledge与Retrieval-Core开发说明.md index f6299c6..911e45b 100644 --- a/docs/development/Knowledge与Retrieval-Core开发说明.md +++ b/docs/development/Knowledge与Retrieval-Core开发说明.md @@ -3,7 +3,7 @@ > 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的 > 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。 -> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。 +> 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 136 项测试通过。 ## 当前实现 @@ -198,7 +198,7 @@ cd backend uv run pytest -q ``` -当前后端完整测试共 120 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把 +当前后端完整测试共 218 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把 数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。 ## 配置 diff --git a/docs/development/MCP-Bridge与Plugin-Host开发说明.md b/docs/development/MCP-Bridge与Plugin-Host开发说明.md index bd43a94..b39245c 100644 --- a/docs/development/MCP-Bridge与Plugin-Host开发说明.md +++ b/docs/development/MCP-Bridge与Plugin-Host开发说明.md @@ -1,6 +1,6 @@ # MCP Bridge 与 Plugin Host 开发说明 -> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。 +> 更新日期:2026-09-02。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。阶段 D 的 Plugin Command 与 Settings 已在其独立开发说明中落地。 ## 1. 目标与实现状态 @@ -235,6 +235,7 @@ backend/extensions/fixtures/mcp-echo - `mcp-fixture.large`:验证结果大小上限; - `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程; - `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。 +- `mcp-fixture.command`:作为 Plugin Command 专用 MCP Target,验证 Context 裁剪、Secret 传递和与 Agent Tool 的隔离。 Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。 @@ -279,4 +280,4 @@ pnpm build - 一键安装前的完整命令展示与确认 UI; - Tool 列表热更新的无中断替换。 -阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。 +阶段 D 已在当前 Plugin Runtime 上增加 Command、Settings、Secret Contract 和命名空间 Storage,且未修改 Agent 使用内部 Tool Contract 的原则。实现细节见《Plugin Command 与 Settings 开发说明》。 diff --git a/docs/development/Plugin-Command与Settings开发说明.md b/docs/development/Plugin-Command与Settings开发说明.md new file mode 100644 index 0000000..4de8a81 --- /dev/null +++ b/docs/development/Plugin-Command与Settings开发说明.md @@ -0,0 +1,117 @@ +# Plugin Command 与 Settings 开发说明 + +> 更新日期:2026-09-03。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界及前端闭环。当前回归基线为后端 136 项测试、前端 32 项测试,TypeScript 类型检查和生产构建通过。 + +## 1. 阶段目标 + +阶段 D 在阶段 C 的 Plugin Runtime 与隔离 MCP Host 上补齐两类宿主贡献: + +- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect; +- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用; +- Frontend:Plugin 详情页提供 Host 状态、动态设置、Secret 管理与命令执行,全局命令面板加载 command_palette Contribution。 + +第三方代码不会导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。 + +## 2. 包内声明 + +Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_sections` 声明贡献标识,并分别提供 `commands.yaml`、`settings.yaml`。安装时宿主要求声明集合与文件内容完全一致,拒绝重复项、越过 Plugin 命名空间的 ID、未声明权限和无效 Schema。 + +`commands.yaml` 的首批字段包括: + +- `command_id`、标题、描述、宿主图标; +- `locations`:`command_palette`、`context_menu` 或 `toolbar`; +- `when` 与允许传入执行器的 Context 字段; +- 参数 JSON Schema、可选权限、执行目标和超时; +- 执行目标必须在宿主白名单 `handler` 与当前插件命名空间的 `mcp_tool` 之间二选一。 +- 可选 `secrets` 字段:只声明当前 Command 允许按需读取的 Secret Setting Key,不暴露给前端 DTO。 + +`settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string`、`number`、`boolean`、`select`、`secret`。宿主会校验默认值、必填项、有限数值边界、Select 选项,以及 Secret 不得携带默认明文;`NaN` 与正负无穷不能用作上下界。 + +仓库内 `text-tools` 是联调 Fixture,覆盖 Command 和五种 Settings 字段类型。 + +## 3. Command 运行链路 + +`CommandRegistry` 只发布处于启用状态的 Plugin Command。Plugin 禁用、Host 不可用或重启时,Command 与 Tool 使用同样的注销/重新注册生命周期,避免前端看到实际不可执行的命令。 + +执行顺序如下: + +1. 查找已注册 Command; +2. 使用 Draft 2020-12 JSON Schema 校验 arguments; +3. 根据 `when` 检查必要上下文; +4. 仅向执行器传递声明过的 Context 字段; +5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target; +6. 按 effect 类型校验专属 payload、可序列化性和 64 KiB 大小上限; +7. 返回统一 `PluginCommandResult`。 + +首批 effect 为 `none`、`notification`、`navigate`、`refresh` 和 `job`。后端分别限制通知级别与消息、宿主路由名、刷新范围和 Job ID;Pydantic 与 TypeScript 均使用同一判别语义,前端不得把 effect 当作任意代码执行。 + +Command 执行器通过受控 Resolver 按需读取 `commands.yaml` 已声明且确实属于当前 Plugin Schema 的 Secret;使用 Secret 的 Plugin 还必须声明并获授 `secrets.use` 权限。读取未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置则返回 `PLUGIN_SECRET_REQUIRED`。Secret 不会并入普通 Settings 字典。Command 审计使用 500 条有界内存队列,仅保留 `command_id`、`plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。 + +MCP Command Target 是专用执行目标,不注册进 Agent `ToolRegistry`,因此模型无法绕过 Command 权限与 Context 裁剪直接调用。宿主通过 `_notesagent` 保留包装传入 `command_id`、已校验 arguments、已裁剪 Context、已校验的非敏感 Settings 和声明过的 Secret。启用阶段只检查目标 `inputSchema` 在顶层 `properties` 中直接声明 `_notesagent: { type: object }`,不自行求解 JSON Schema,也不用空对象伪造业务数据;引用和组合约束可以放在 `_notesagent` 对象内部。执行阶段再用保留的完整 Schema 和官方 Draft 2020-12 Validator 校验真实信封。MCP Server 必须返回结构化的白名单 effect。远程原始错误不直接透传给 HTTP 调用方。插件仍不能把模块路径或 Shell 字符串作为执行器。 + +Command 与 Tool 的 JSON Schema 只允许当前文档内的 Fragment 引用(`#...`);宿主在注册前递归拒绝 `$ref` / `$dynamicRef` 指向的文件、HTTP 或其他外部资源,避免 Schema 校验触发未授权 I/O。文档内引用使用 Draft 2020-12 Resource Resolver 预检,嵌套 `$id` 创建的新资源及其 Anchor 按各自作用域解析,无法解析的引用在注册阶段返回稳定错误。 + +## 4. Settings 与 Secret 边界 + +普通 Settings 以 Plugin 为命名空间持久化到: + +```text +APP_DATA_DIR/plugins/settings.json +``` + +该文件只包含: + +- 当前 Schema 版本; +- 非敏感字段值; +- Secret 的确定性定长引用,格式为 `plugin.`。 + +Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。`plugin.*` 是保留命名空间,通用凭据 API、Provider 配置、Provider 临时测试凭据和 Provider Resolver 均不得访问,防止覆盖、删除或外发 Plugin Secret。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。 + +没有默认值的 `required` 普通字段必须在启用 Plugin 前配置。Enable 和每次 Command Execute 都会重新检查有效设置;缺失时返回 `PLUGIN_SETTINGS_REQUIRED`,不启动 MCP Host,也不调用 Command handler。 + +读取持久化引用时,宿主会重新计算并核对 `plugin.`,引用不匹配即按损坏存储拒绝处理,不能借由篡改 `settings.json` 读取或删除 Provider 等其他命名空间的凭据。删除单个 Secret 或卸载 Plugin 时先原子更新 Settings 引用,再删除加密凭据;底层删除失败会恢复原引用。多 Secret 卸载使用一次凭据表原子替换,避免分批删除部分删除。 + +开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 Keychain,HTTP Contract 无需因此改变。 + +## 5. HTTP 与前端 Service + +后端已实现: + +```text +GET /api/plugin-contributions/commands?location=command_palette +POST /api/plugin-contributions/commands/{command_id}/execute +GET /api/plugins/{plugin_id}/settings +PUT /api/plugins/{plugin_id}/settings +PUT /api/plugins/{plugin_id}/settings/{key}/secret +DELETE /api/plugins/{plugin_id}/settings/{key}/secret +``` + +前端 `pluginService` 提供对应方法及 Wire DTO,Plugin 详情页据此展示 MCP Host 状态和重启入口、动态生成五类设置字段、独立写入或删除 Secret,并执行带参数的 Plugin Command。全局命令面板打开时获取 `command_palette` 命令;需要必填参数的命令会引导用户进入详情页填写。调用方必须使用服务层,不自行拼接路径。 + +Secret 明文仅存在于当前密码输入框绑定的组件内存,提交后立即清空;不得写入 Pinia、LocalStorage、普通 Settings 请求或调试日志。前端不会读取 Secret 明文,只展示后端返回的 `configured` 状态。 + +## 6. 主要错误边界 + +- Command 未注册、冲突、参数或 Context 无效; +- 执行超时、执行器异常、effect 无效或过大; +- Settings Schema 无效、版本冲突、字段类型/边界错误或运行时必填值缺失; +- Secret 字段不存在、空 Secret、凭据存储异常; +- Settings JSON 根结构或 Plugin 命名空间损坏。 + +以上错误统一转换为 `ExtensionError` 和稳定业务错误码,HTTP 层不暴露内部堆栈、Secret 或插件返回的原始异常。 + +## 7. 验证 + +```powershell +cd backend +uv run pytest + +cd ../frontend +pnpm test -- --run +pnpm type-check +pnpm build +``` + +阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径、前端 Service 请求格式、Host 状态展示和动态 Secret 表单。 + +生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。 diff --git a/docs/development/前端写作体验优化开发说明.md b/docs/development/前端写作体验优化开发说明.md index 99f4f44..aaf4841 100644 --- a/docs/development/前端写作体验优化开发说明.md +++ b/docs/development/前端写作体验优化开发说明.md @@ -1,6 +1,6 @@ # 前端写作体验优化开发说明 -> 更新日期:2026-08-30。本文所述优化均已进入当前分支;前端完整回归基线为 14 项测试通过,TypeScript 检查和 Vite 生产构建通过。 +> 更新日期:2026-09-02。本文所述优化均已进入 `main`;当前前端完整回归基线为 29 项测试通过,TypeScript 检查和 Vite 生产构建通过。 ## 1. 本次目标 @@ -103,7 +103,7 @@ pnpm build pnpm test ``` -验证结果:TypeScript 类型检查与 Vite 生产构建均通过。当前前端完整回归测试共 14 项;其中写作与文件切换相关回归覆盖: +验证结果:TypeScript 类型检查与 Vite 生产构建均通过。当前前端完整回归测试共 29 项;其中写作与文件切换相关回归覆盖: - 顶部工具栏对选区应用加粗; - 浮动工具栏对选区应用斜体; diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index 40a5dd3..964912f 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -1,6 +1,6 @@ # 前端壳子与接口层开发说明 -> 更新日期:2026-08-30 +> 更新日期:2026-09-02 > 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。 > 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。 @@ -186,13 +186,13 @@ pnpm build ```text pnpm build passed -pnpm test 27 passed -uv run pytest 92 passed +pnpm test 29 passed +uv run pytest 136 passed preview smoke HTTP 200 git diff --check passed ``` -当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。 +当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 136 项测试结果,也不涉及产品代码。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 diff --git a/docs/development/模型提供商与模型发现开发说明.md b/docs/development/模型提供商与模型发现开发说明.md index 598ede8..825e501 100644 --- a/docs/development/模型提供商与模型发现开发说明.md +++ b/docs/development/模型提供商与模型发现开发说明.md @@ -1,6 +1,6 @@ # 模型提供商与模型发现开发说明 -> 更新日期:2026-08-30。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。 +> 更新日期:2026-09-02。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。 ## 1. 本次目标 @@ -104,4 +104,4 @@ pnpm build 自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。 -当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 +当前完整回归基线:后端 136 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。`plugin.*` 为 Plugin Secret 保留命名空间,Provider 配置、临时测试凭据和通用凭据 API 均拒绝该前缀。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。 diff --git a/docs/development/独立MCP-Server配置中心开发说明.md b/docs/development/独立MCP-Server配置中心开发说明.md new file mode 100644 index 0000000..e0d0a28 --- /dev/null +++ b/docs/development/独立MCP-Server配置中心开发说明.md @@ -0,0 +1,141 @@ +# 独立 MCP Server 配置中心开发说明 + +> 更新日期:2026-09-03。本文记录第二阶段 C.1 的完整实现;独立 MCP Server Registry 与 Plugin 自带 MCP Host 是两个并列入口。 + +## 1. 已实现范围 + +- 独立 Server 的创建、读取、版本化编辑、删除和 Tool 摘要查询; +- `stdio`、Streamable HTTP 和旧版 HTTP+SSE 三种 Transport; +- stdio 可执行文件、参数、普通/加密环境变量,以及 HTTP URL、普通/加密 Header; +- 配置摘要确认、连接测试、启停、异常状态与最近一次测试结果; +- MCP initialize、`tools/list`、`tools/call`、取消与动态 Tool 注册,名称为 `mcp.{server_id}.{tool}`; +- Streamable HTTP Session、协议版本 Header、JSON/SSE POST 响应、可选 GET 事件流和 `Last-Event-ID` 重连; +- 旧 HTTP+SSE 的 endpoint 事件与消息 POST,并强制消息地址和配置地址同源; +- 前端表单/JSON 双模式、三种模板、高风险变更确认及请求期 Secret 输入。 + +Streamable HTTP 按 MCP 当前规范实现;SSE 仅用于兼容旧 Server,不应作为新部署首选。 + +## 2. 配置、版本与 Secret + +普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。更新请求必须携带读取到的 `version`;版本过期返回 `409 MCP_SERVER_VERSION_CONFLICT`,避免多个页面互相覆盖。改变 Transport、命令、URL、Header、环境变量或权限后,旧授权和测试结果立即失效。 + +`backend/data/mcp/` 是本机运行数据,包含连接配置、授权状态和第三方进程工作目录,不属于团队共享配置。`.gitignore` 忽略整个目录以及 `server.json`、`servers.json` 文件名;不得强制添加到 Git。提交前检查暂存文件清单,不要将本地密钥、连接配置或运行数据推送到远程。 + +Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据存储。查询 API 只返回环境变量或 Header 是否配置,不返回明文。删除 Server 或移除 Secret 键会同步清理密文。前端密码框提交后立即清空;用户主动粘贴到 JSON 的密钥仅在当前编辑会话中暂存,解析后从 JSON 中移除,不写入 localStorage、普通配置或日志。 + +环境变量的凭据 ID 使用区分大小写的 v2 名称规则,Header ID 保持大小写不敏感。更新配置按凭据 ID 的差集删除密文,因此 `Authorization` 改为 `authorization` 不会丢失认证信息。启动时对无歧义的旧环境变量凭据原子迁移密文,不覆盖新 ID 已有的值;若旧配置把 `TOKEN`、`token` 合并存到了同一个 ID,无法推断原来的两个值,会保留旧密文、停用连接并要求重新录入和测试。删除服务器时也会清理这些保留的旧密文。 + +### 2.1 JSON 导入与 API Key 填写 + +前端支持 NotesAgent 完整/精简配置、单个 `command / args / env` 配置,以及只含一个服务器的 `mcpServers` 包装。JSON 与表单之间切换会补齐数组、对象及超时默认值,并校验字段类型。批量导入暂不支持;后端配置接口仍只接收 NotesAgent DTO,兼容转换发生在前端。 + +可以先声明 `secret_environment_keys`,保存后在服务器卡片的密码框填写密钥;也可以把密钥放进 JSON 的 `environment` 或通用配置的 `env`。前端会将已声明的敏感变量,以及名称含 API Key、Token、Secret、Password、Authorization、Cookie、Credential 的常见字段拆出:普通配置请求只包含键名,密钥另经 Secret API 加密保存。其他敏感字段必须显式声明,不能只依赖名称识别;命令与参数中不要携带密钥。 + +例如 MiniMax 的输入结构如下,占位值需在自己的本地页面替换,不要把真实密钥贴进聊天或提交到 Git: + +```json +{ + "name": "MiniMax Coding Plan", + "command": "uvx", + "args": ["--index-url", "https://pypi.tuna.tsinghua.edu.cn/simple", "--with", "mcp<2", "minimax-coding-plan-mcp", "-y"], + "environment": { + "MINIMAX_API_HOST": "https://api.minimaxi.com", + "MINIMAX_API_KEY": "<在本地填入新密钥>" + }, + "secret_environment_keys": ["MINIMAX_API_KEY"], + "startup_timeout_seconds": 120, + "tool_timeout_seconds": 300 +} +``` + +旧版前端将 `environment.MINIMAX_API_KEY` 与 `secret_environment_keys` 原样一起发送,触发后端“普通与敏感变量不可同名”的校验。这是配置保存失败,不是模型服务返回的鉴权失败。现在在前端拆分两类请求,后端仍保留互斥校验。 + +导入兼容规则:`env` 转为 `environment`;`timeout` 作为启动超时;`sse_read_timeout` 作为工具等待上限,不保留其原客户端 SSE 读取超时语义。启动超时范围为 1–120 秒,工具超时为 1–300 秒。URL 必须是纯地址,不能使用 Markdown 链接,JSON 中不能包含 `\_` 这样的非法转义。 + +另一个已修复的失败原因是运行时适配层复用了 `PluginBackend` 的整数超时与 60 秒启动上限,导致合法的 120 秒或小数超时配置在保存返回、读取或测试时失败。独立 Server 现在使用专门的 Bridge 适配模型,保留自己的浮点超时范围,不改变 Plugin 清单原有约束。已有的 120 秒记录可直接读取,无需删库重建。 + +配置保存成功但后续 Secret 写入失败时,窗口保留服务器 ID、新版本和未写入的密钥。点击保存会更新同一服务器并重试,不重复创建记录;取消会清除未保存密钥,已经保存的服务器和凭据不会回滚。错误信息显示在配置窗口内。保存配置不会自动运行第三方进程,仍需确认、测试和启用。 + +暂存的 Header Secret 与已保存凭据使用一致的大小写规则:将 `Authorization` 改为 `authorization` 不会丢弃尚未保存的值,提交时采用当前声明名。重新输入同一 Header 的值会覆盖旧草稿;真正删除声明才清除草稿。环境变量仍区分大小写,不会把 `TOKEN` 的草稿转交给 `token`。 + +跨 Registry 与凭据存储的删除以“失败后可重试”为顺序约束:先原子清理密文,再提交新版本或删除 Registry 记录。凭据存储失败时保留原版本和 Server 记录,避免出现返回 500 但配置已提交、版本无法重试或密文失去清理入口的状态。 + +写入或删除 Secret 会先停用正在运行的连接、注销动态 Tool,并撤销当前配置的测试通过状态;必须使用新凭据重新测试后才能启用。这样页面展示的凭据状态不会与运行中进程实际持有的旧凭据不一致。 + +## 3. 启用与运行时规则 + +一次连接按以下顺序执行: + +1. 用户检查服务端生成的连接摘要并确认当前摘要; +2. 后端临时连接,完成 initialize 和 `tools/list` 后关闭连接; +3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 Tool; +4. 停用、删除、超时或异常退出会注销 Tool 并关闭连接。 + +运行期连接异常或 Tool 列表变化时,Registry 会在同一生命周期临界区内标记不可用、注销 Tool,并从 Bridge 移除 Host。每次启动分配独立的连接代次;失败回调取得锁后先核对代次,旧连接延迟到达的回调不能停用新连接。停用、测试结束及关闭服务时撤销对应代次。 + +所有独立 MCP API 都通过工作线程执行,包括新增、确认授权和读取接口。虽然部分操作不直接访问网络,但仍可能等待正在测试或启动的连接持有的锁,不能在 FastAPI 事件循环上同步等待。 + +HTTP Header 中 `Host`、`Content-Type`、`MCP-Session-Id` 等协议保留项不可由配置覆盖。URL 不允许内嵌凭据或 Fragment。旧 SSE 返回的 POST endpoint 必须与初始 URL 同源,防止认证 Header 被转发到其他站点。 + +启动与 Tool 请求超时会同时应用于业务等待和底层 HTTP 请求;旧 SSE 的 endpoint 等待也使用启动超时。非主动结束的旧 SSE 事件流视为 Host 不可用,宿主随后注销 Tool。注册表加载时逐条校验 Server ID、配置字段、Transport 组合和摘要格式,损坏记录统一返回 `MCP_REGISTRY_INVALID`。 + +两种 HTTP Transport 共用有界 SSE 行解析器:按响应字节块检查未完成行及当前事件的累计大小,再扩展缓冲区,不依赖 `iter_lines()` 先缓存完整行。持续无换行输入也会及时触发上限;解析兼容跨块 UTF-8、首行 BOM、LF/CR/CRLF、多行 data 和事件间计数重置。`tests/test_mcp_sse_limits.py` 覆盖这些边界,防止仅在完整行生成后检查大小。 + +stdio 命令不经过 Shell,管道、重定向和命令拼接不会被解释。Windows 使用新进程组并通过 `taskkill /T` 回收子树;POSIX 使用独立 session/process group 并向进程组发信号。Python 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。 + +`uvx` 模板使用 `--isolated`、明确的 `--from` 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 `403 MCP_SANDBOX_REQUIRED`;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。 + +## 4. 接口 + +```text +GET /api/mcp/servers +POST /api/mcp/servers +GET /api/mcp/servers/{server_id} +PUT /api/mcp/servers/{server_id} +DELETE /api/mcp/servers/{server_id} +GET /api/mcp/servers/{server_id}/tools +POST /api/mcp/servers/{server_id}/trust +POST /api/mcp/servers/{server_id}/test +POST /api/mcp/servers/{server_id}/enable +POST /api/mcp/servers/{server_id}/disable +PUT /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header +DELETE /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header +``` + +完整字段、状态和错误码见《第二阶段接口契约-开发版》。协议实现参考 MCP 官方的 [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 与 [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)。 + +## 5. 验证 + +```powershell +cd backend +uv run pytest -q tests/test_mcp_registry.py tests/test_extension_core.py + +cd ../frontend +npm run type-check +npm test +npm run build +``` + +后端测试使用无需网络或真实密钥的 stdio Fixture,以及 `httpx.MockTransport` 驱动的确定性 HTTP/SSE Server Fixture。覆盖摘要授权、Secret 不回显、版本冲突、生产门禁、重启恢复、Streamable HTTP Session/Header/工具调用及旧 SSE 同源校验;新增覆盖旧回调隔离、路由线程卸载、凭据大小写差异与旧密文迁移。前端覆盖模板切换、JSON 默认值与格式兼容、明文拆分、模式切换、部分保存失败重试、取消清理、测试失败和删除确认。此处的 MiniMax 配置转换测试使用假密钥,不等同于真实 MiniMax 网络调用验证。 + +## 6. 后续边界 + +### 本轮 P1/P2 修复验收 + +| 审阅问题 | 修复方式 | 回归验证 | +| --- | --- | --- | +| P1:新增或授权等待生命周期锁时阻塞事件循环 | 独立 MCP 路由统一交给工作线程 | `test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive`:分别阻塞 create/trust,在锁释放前仍能执行健康检查 | +| P2:旧失败回调误停新连接 | 回调在锁内核对连接代次 | `test_old_failure_callback_cannot_stop_replacement_host`:旧回调排队期间重启连接,释放锁后新连接仍可用,当前代次的失败仍正确停用 | +| P2:精简 JSON 切换模式或编辑保存报错 | 运行时校验、默认值补全、统一转换 | `configuration.spec.ts` 与 `McpServersView.spec.ts`:精简 JSON、编辑版本、模式切换和 Secret 保存重试 | +| P2:Header 大小写改名删除凭据 | 按规范化凭据 ID 而非原始键名计算差集 | `test_header_case_only_rename_preserves_secret` | +| P2:大小写不同的环境变量覆盖同一凭据 | 区分大小写的 v2 ID,带迁移标记的旧密文迁移 | `test_environment_secrets_are_case_sensitive_and_delete_independently` 及 legacy migration 测试,包含删除后不复活旧密钥 | +| P1:SSE 无换行输入在大小校验前无限缓冲 | 在行拼接前校验字节数与事件累计大小 | `test_mcp_sse_limits.py`,包括小块持续输入和跨块换行 | +| P2:Header 大小写改名丢失未保存密钥 | 草稿使用规范化名称匹配,并重新绑定当前声明名 | `configuration.spec.ts` 与页面保存回归测试 | + +这些修复不放宽 stdio 的 JSON-RPC 校验。第三方程序向 stdout 打印普通日志造成的握手失败,应由服务端调整输出或使用不打印日志的启动入口处理。 + +### 后续工作 + +- 增加真实第三方 Server 的兼容矩阵;确定性 Fixture 只能证明宿主协议行为,不能代表所有实现兼容; +- C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract; +- 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。 diff --git a/docs/guides/第一阶段测试验证操作手册.md b/docs/guides/第一阶段测试验证操作手册.md index 1194ae9..6925272 100644 --- a/docs/guides/第一阶段测试验证操作手册.md +++ b/docs/guides/第一阶段测试验证操作手册.md @@ -1,9 +1,11 @@ # 第一阶段测试验证操作手册 -> 适用基线:2026-08-30 `main` -> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查 +> 适用基线:2026-09-02 `main` +> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查 > 验证范围:Vue Web 前端、FastAPI、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 与开发阶段凭据链路 +> 状态说明:本文保留第一阶段功能验收口径;当前全量自动化回归同时覆盖第二阶段已经合并的 Agent Trace 持久化与 SSE 恢复、stdio MCP Host、Plugin Command/Settings Contract 和前端 Service,但不反向修改第一阶段的交付定义。 + ## 1. 验证目标 本手册用于确认第一阶段已经形成可运行的本地知识工作流: @@ -18,7 +20,7 @@ → Skill / Plugin 完成生命周期与 Tool 注册 ``` -当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。 +当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、真实音频模型和 Sync Server。独立 stdio MCP Plugin Host 已在第二阶段完成,并进入当前全量回归测试。 ## 2. 环境准备 @@ -68,7 +70,7 @@ uv run pytest -q -p no:cacheprovider 当前基线: ```text -81 passed +136 passed ``` 通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。 @@ -83,11 +85,11 @@ pnpm test 当前基线: ```text -11 test files passed -27 tests passed +12 test files passed +29 tests passed ``` -通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。 +通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、SSE 恢复游标、Plugin Command/Settings Service、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。 ### 3.3 类型检查与生产构建 @@ -330,7 +332,7 @@ Invoke-RestMethod -Method Delete -Uri "$apiBase/notes/$noteId" - 没有真实密钥、生成目录或运行数据进入 Git; - 已记录测试环境、提交、结果、警告和遗留问题。 -外部 OpenAI/DeepSeek、Tauri、Stronghold、真实文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。 +外部 OpenAI/DeepSeek、Tauri、Stronghold、原生多 Vault 文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。 ## 9. 验收记录模板 diff --git a/docs/retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md b/docs/retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md index cf1b432..d639789 100644 --- a/docs/retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md +++ b/docs/retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md @@ -3,7 +3,7 @@ > 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。 > 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。 -> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*` 与 `rag.search` Tool 接入 Agent Runtime。 +> 2026-09-02 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 136 项通过,Knowledge/Retrieval 已通过 `notes.*` 与 `rag.search` Tool 接入 Agent Runtime。 ## 1. 背景 diff --git a/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md b/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md new file mode 100644 index 0000000..0c6ccf8 --- /dev/null +++ b/docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md @@ -0,0 +1,464 @@ +# Plugin Command 与 Settings 问题与修复复盘 + +> 审阅与修复日期:2026-09-02 +> 涉及分支:`feat/plugin-command-settings` +> 初始功能提交:`6a08ad8 feat(extension): 实现插件命令与设置贡献` +> 最终修复提交:`d39ae72 fix(extension): 收紧插件命令运行时契约` +> 合并提交:`ff3da5d Merge pull request 'feat(extension): 实现 Plugin Command 与 Settings Contribution' (#10)` +> 文档用途:记录阶段 D 从首次提交、连续审阅到合并期间发现的问题,说明形成原因、实际后果、解决思路和最终方案,供后续开发、问题定位、比赛材料和技术博客写作使用。 + +## 1. 背景与结论 + +阶段 D 在既有 Extension Core、MCP Bridge 和 Plugin Host 上增加 Plugin Command 与 Settings Contribution。初始实现已经具备命令注册、参数校验、设置持久化、Secret 加密存储和前端 Service,但首次提交仍把若干“接口能够调用”误当成了“安全边界已经闭合”。 + +本次分支没有在初始功能提交后直接合并,而是围绕清单输入、凭据隔离、JSON Schema、MCP 协议、事务一致性和前后端 Contract 连续审阅。最终共形成 1 个功能提交和 6 个修复提交,处理 12 类问题: + +| 编号 | 问题 | 级别 | 处理结果 | +| --- | --- | --- | --- | +| D-01 | Plugin Secret 可被通用凭据接口或 Provider 引用 | P0 | 建立 `plugin.*` 保留命名空间和双层访问门禁 | +| D-02 | Command 与 Settings 清单边界不完整 | P1 | 收紧空值、重复项、权限、类型和数值边界校验 | +| D-03 | MCP Command Target 只有声明,没有真实执行链路 | P0 | 增加专用 MCP 命令目标并与 Agent Tool 隔离 | +| D-04 | JSON Schema 外部引用可能触发越权 I/O | P0 | 只允许当前文档内 Fragment 引用 | +| D-05 | Secret 引用可被篡改并跨凭据命名空间 | P0 | 改用定长哈希引用并在读取、删除前重新核对 | +| D-06 | Secret 删除和插件卸载缺少事务一致性 | P0 | 原子替换凭据表,失败时恢复 Settings 引用 | +| D-07 | Schema 引用校验忽略嵌套 `$id` 资源作用域 | P1 | 使用 Draft 2020-12 Resource Resolver 按资源解析 | +| D-08 | MCP Command 未收到普通 Settings | P1 | 在固定信封中同时传递 Settings 与声明过的 Secret | +| D-09 | Command Effect 是任意字典,前后端边界过宽 | P0 | 改为五类判别联合并限制各自 Payload | +| D-10 | 必填普通设置没有阻止启用和执行 | P1 | 在 Enable 与 Execute 两个入口增加运行时门禁 | +| D-11 | MCP 工具 Schema 被发现后丢弃,真实信封未校验 | P0 | 保存目标 Schema,并在调用前校验完整信封 | +| D-12 | 启用探测和回归测试存在假阳性或假阴性 | P1 | 使用最小协议标记,移除自制求解器并补强测试 | + +最终验证基线为后端 136 项测试、前端 29 项测试、TypeScript 类型检查、前端生产构建、`uv lock --check` 和 `git diff --check` 通过。PR #10 已合并到 `main`。 + +## 2. 提交与审阅过程 + +| 顺序 | 提交 | 主要内容 | +| --- | --- | --- | +| 1 | `6a08ad8` | 首次实现 Plugin Command、Settings、Secret API 和前端 Service | +| 2 | `c3ef9df` | 收紧 Plugin Secret、Provider 凭据和 Command 清单边界 | +| 3 | `9e680a0` | 补齐真实 MCP Command Target,限制 JSON Schema 外部引用 | +| 4 | `022c322` | 改造 Secret 引用,修复删除和卸载事务 | +| 5 | `c06b962` | 按 Draft 2020-12 资源作用域修复 Schema 引用解析 | +| 6 | `eb3464b` | 补回 MCP Command 信封中的普通 Settings | +| 7 | `d39ae72` | 收紧 Effect、必填设置和 MCP 运行时 Contract,修复测试盲点 | +| 合并 | `ff3da5d` | PR #10 合并进入 `main` | + +这段过程说明,Extension Core 的风险主要不在正常路径能否运行,而在不同入口是否共享同一套边界。HTTP、Provider Factory、Plugin Runtime、MCP Host、Settings Store 和测试 Fixture 只要有一个入口绕过限制,就可能形成跨命名空间访问、错误执行或假测试通过。 + +## 3. D-01:Plugin Secret 可被通用凭据接口或 Provider 引用 + +### 原因 + +初始实现把 Plugin Secret 存入已有 `EncryptedCredentialStore`,但只在 Plugin Settings API 中隐藏明文,没有为凭据 ID 建立用途隔离。通用凭据 API 可以读写或删除同名 ID,Provider 配置也可以把 Plugin Secret 的凭据引用当作自己的 API Key 使用。 + +此外,Command 能否读取 Secret 只依赖 Plugin 拥有 `secrets.use` 权限,没有继续限制到当前 Command 在 `commands.yaml` 中明确声明的字段。 + +### 后果 + +- 前端或其他模块可以覆盖、删除 Plugin 私有 Secret; +- Provider 可能把插件密钥发送给外部模型服务; +- 同一 Plugin 内权限较低的 Command 可以读取与自己无关的 Secret; +- API 没有回显明文并不代表 Secret 没有发生横向流动。 + +### 解决思路 + +Secret 隔离必须同时覆盖存储命名空间、公共 HTTP 入口、Provider 解析入口和 Command 字段级授权,不能只在返回 DTO 上隐藏值。 + +### 解决方案 + +- 将 `plugin.*` 设为 Plugin Settings 专用保留命名空间; +- 通用凭据查询、写入和删除接口拒绝访问该前缀; +- 在 `ProviderFactory` 外包一层 `ProviderCredentialResolver`,即使绕过 HTTP 配置校验也不能读取 Plugin Secret; +- Command 通过 `secrets` 字段声明允许读取的 Setting Key; +- Resolver 同时检查插件权限、字段是否存在、字段类型和 Command 声明; +- 未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置返回 `PLUGIN_SECRET_REQUIRED`; +- 审计事件不记录 arguments、effect 或 Secret。 + +## 4. D-02:Command 与 Settings 清单边界不完整 + +### 原因 + +YAML 解析成功只说明语法可读,并不代表清单结构满足宿主协议。初始校验对 `commands:` 空值、重复 Secret、未声明权限、无效执行目标、Settings 默认值及非有限数值边界等情况覆盖不足。 + +例如 YAML 中只有 `commands:` 时,解析结果是 `null` 而不是空数组;`NaN` 和正负无穷虽然属于 Python 浮点值,却不能成为可移植的表单边界。 + +### 后果 + +- 安装阶段可能放过无法执行的 Command; +- 空清单在后续遍历时变成内部异常,而不是稳定业务错误; +- 前后端对数值范围和默认值产生不一致理解; +- 重复声明或越过 Plugin 命名空间的 ID 会污染全局注册表; +- 错误只能到运行期暴露,定位成本更高。 + +### 解决思路 + +把 Plugin 包视为不可信输入,在安装阶段完成结构、语义和权限的完整验证,并将解析异常统一转换成稳定的 `ExtensionError`。 + +### 解决方案 + +- 要求 `contributes` 声明与 `commands.yaml`、`settings.yaml` 内容完全一致; +- 拒绝 `null` Command 列表、重复 ID、重复 Secret 和越过 Plugin 命名空间的标识; +- 执行目标只能在受控 `handler` 与当前 Plugin 的 `mcp_tool` 中二选一; +- 校验 `when`、Context、Location 和权限白名单; +- Settings 类型限定为 `string`、`number`、`boolean`、`select`、`secret`; +- 校验默认值、Select 选项、必填规则和最小/最大边界; +- 拒绝 `NaN`、正无穷和负无穷; +- 清单错误统一返回可定位的稳定错误码。 + +## 5. D-03:MCP Command Target 只有声明,没有真实执行链路 + +### 原因 + +初始 Command 执行器只支持宿主内置 Handler。接口和规划中虽然存在 MCP Command 的概念,但 Runtime 没有把 Command 绑定到 MCP 工具,也没有定义 Command Context、Settings 和 Secret 如何进入 MCP 请求。 + +直接复用 Agent `ToolRegistry` 看似省事,却会把“用户主动执行的插件命令”和“模型可自主调用的 Agent Tool”混成同一种能力。 + +### 后果 + +- MCP 插件声明的 Command 实际无法执行; +- 如果简单注册为 Agent Tool,模型可能绕过命令位置、Context 裁剪和 Secret 声明直接调用; +- Command 超时、Effect 和错误边界无法统一; +- 前端看到命令已注册,点击后却只能得到运行时错误。 + +### 解决思路 + +为 MCP Command 建立独立执行通道。它可以复用 MCP 连接,但不能自动进入 Agent Tool Registry;宿主负责构造固定协议信封并验证返回的白名单 Effect。 + +### 解决方案 + +- `commands.yaml` 支持当前 Plugin 命名空间内的 `mcp_tool` 目标; +- Runtime 在 Plugin 启用后绑定目标,在禁用、异常退出和重启时同步注销; +- MCP Command 不注册到 Agent `ToolRegistry`; +- 宿主只传递已校验 arguments、已裁剪 Context、有效 Settings 和当前 Command 声明的 Secret; +- MCP 返回结果必须转换成受控 Effect; +- 远程原始异常、过大结果和超时统一映射为稳定宿主错误。 + +## 6. D-04:JSON Schema 外部引用可能触发越权 I/O + +### 原因 + +Command 和 MCP Tool 都接受插件提供的 JSON Schema。初始实现直接交给校验器处理 `$ref` 或 `$dynamicRef`,没有限制引用 URI。恶意或错误 Schema 可以引用本地文件、HTTP 地址或其他外部资源。 + +### 后果 + +- Schema 校验可能读取宿主文件或发起未授权网络请求; +- 安装一个插件就可能产生隐式 I/O; +- 离线环境中校验结果不稳定; +- 外部资源变化会让相同插件包得到不同验证结果; +- Command 和 Agent Tool 如果采用不同规则,会出现新的绕过路径。 + +### 解决思路 + +当前阶段不需要跨文件 Schema。宿主应只允许当前文档内部的 Fragment 引用,并在注册阶段递归检查所有 Schema 节点。 + +### 解决方案 + +- Command 与 Tool 共用 `schema_security` 校验边界; +- 递归扫描 `$ref` 和 `$dynamicRef`; +- 只允许以 `#` 开头的当前文档 Fragment; +- 拒绝文件、HTTP 和其他外部资源 URI; +- 无法解析的本地引用在安装或注册阶段直接失败; +- 运行期继续使用官方 Draft 2020-12 Validator 校验数据。 + +## 7. D-05:Secret 引用可被篡改并跨凭据命名空间 + +### 原因 + +初始 Settings 文件保存的 Secret 引用由可读的 Plugin ID 和 Setting Key 拼接而成。长度随名称增长,而且 Runtime 读取引用时默认信任磁盘内容,没有重新确认该引用确实属于当前字段。 + +本地文件损坏或被篡改后,一个 Plugin Setting 可以被改为指向 Provider 凭据或另一个 Plugin 的 Secret。 + +### 后果 + +- 长 Plugin ID 和 Setting Key 可能超过凭据 ID 长度限制; +- Settings 文件泄露内部字段名称; +- 篡改引用可能造成跨命名空间读取或删除; +- 卸载一个插件时可能误删其他模块的凭据。 + +### 解决思路 + +Secret 引用应由宿主确定性生成,长度固定,并在每次敏感操作前由当前 `plugin_id + setting_key` 重新计算,而不是信任持久化文件。 + +### 解决方案 + +使用以下语义生成引用: + +```text +plugin. +``` + +- 引用长度固定且符合凭据 ID 规则; +- Settings Store 只保存引用和 `configured` 状态,不保存明文; +- 读取、覆盖、删除和卸载前重新计算期望引用; +- 引用不匹配时按损坏存储拒绝处理; +- 增加跨命名空间篡改回归测试。 + +## 8. D-06:Secret 删除和插件卸载缺少事务一致性 + +### 原因 + +Secret 同时涉及 Settings 引用文件和加密凭据文件。初始删除流程按顺序修改两个存储,但任一步失败都没有完整回滚。插件卸载多个 Secret 时逐条删除,执行到一半失败会留下部分清理状态。 + +### 后果 + +- Settings 显示未配置,但密文仍残留; +- 凭据已删除,Settings 却仍显示已配置; +- 多 Secret 卸载可能只删除前几项; +- 重试操作无法判断上一次执行到哪里; +- 用户以为插件卸载已清除密钥,实际磁盘仍可能保留数据。 + +### 解决思路 + +把引用更新和凭据删除看作一个逻辑事务。底层单文件凭据表应一次构造新状态并原子替换;跨 Settings 与 Credential Store 的操作需要显式补偿回滚。 + +### 解决方案 + +- `EncryptedCredentialStore` 增加多凭据原子删除; +- 先验证全部目标引用,再生成新的凭据表; +- 通过临时文件和原子替换一次提交; +- 删除单个 Secret 时,凭据删除失败则恢复原 Settings 引用; +- 卸载 Plugin 时,批量删除失败则恢复完整 Settings 命名空间; +- 错误统一转换为稳定存储错误,避免部分成功被当作完整成功。 + +## 9. D-07:Schema 引用校验忽略嵌套 `$id` 资源作用域 + +### 原因 + +第一版本地引用检查把整个 Schema 当成单一 Fragment 树,用根文档指针或 Anchor 查找所有引用。Draft 2020-12 允许嵌套 `$id` 创建新的 Schema Resource;资源内部的 `#anchor` 应相对于新的 Base URI 解析,根资源也不能反向使用嵌套资源的 Anchor。 + +### 后果 + +- 合法的嵌套资源引用被误拒绝; +- 根 Schema 可能错误引用只属于子资源的 Anchor; +- 宿主预检结果与官方运行时 Validator 不一致; +- 同一 Schema 在安装阶段通过,却可能在执行阶段失败,反之亦然。 + +### 解决思路 + +安全限制仍然是“禁止外部资源”,但本地资源内部的解析语义必须遵守 JSON Schema 标准,不能自己用字符串和全局 Anchor 集合近似实现。 + +### 解决方案 + +- 引入与 Draft 2020-12 Validator 配套的 Resource Registry; +- 为根资源和嵌套 `$id` 建立正确作用域; +- 每个引用按其所在资源的 Base URI 解析; +- 保留外部资源拒绝策略; +- 增加“根资源不能使用子资源 Anchor”和“子资源可使用自身 Anchor”的成对测试。 + +## 10. D-08:MCP Command 未收到普通 Settings + +### 原因 + +真实 MCP Command 链路补齐后,固定信封传递了 Command ID、Arguments、Context 和 Secret,但遗漏了已经通过 Schema 校验的普通 Settings。声明式内置 Handler 能读取 Settings,MCP Handler 却不能,两个执行目标语义不一致。 + +### 后果 + +- 用户在插件设置页修改普通配置,对 MCP Command 不生效; +- 插件只能把非敏感设置错误地编码进 arguments 或 Secret; +- 内置 Handler 测试通过会掩盖 MCP 路径的缺口; +- 插件从内置实现迁移到 MCP 后行为发生变化。 + +### 解决思路 + +内置 Handler 与 MCP Command 应消费同一份运行时配置。差别只在执行介质,不在 Command Contract。 + +### 解决方案 + +- MCP 固定信封增加 `settings`; +- Settings 由 `PluginSettingsStore.runtime_values()` 产生; +- 只传递当前 Plugin Schema 中有效的非敏感字段; +- Secret 继续放在独立 `secrets` 命名空间; +- Fixture 回显非敏感配置用于断言,但不回显 Secret; +- 增加 Settings 实际到达 MCP Server 的集成测试。 + +## 11. D-09:Command Effect 是任意字典,前后端边界过宽 + +### 原因 + +初始 `PluginCommandEffect` 只有 `type` 和任意 `payload`。宿主虽然限制 Effect 名称和总体大小,却没有限制 Payload 字段、路由名称、刷新范围或通知级别。前端 TypeScript 也只能把 Payload 当成普通对象处理。 + +### 后果 + +- 插件可以返回前端从未支持的字段和路由; +- 前端需要在运行时猜测 Payload 结构; +- `navigate` 或 `refresh` 可能越过宿主允许的目标; +- OpenAPI 无法表达不同 Effect 的必填字段; +- 无效 Effect 往往要到页面执行时才暴露。 + +### 解决思路 + +Effect 是宿主能力协议,不是插件任意消息。每种 Effect 都应是独立、封闭、可判别的 Contract,并在进入 HTTP 响应前完成验证。 + +### 解决方案 + +- 后端建立 `notification`、`navigate`、`refresh`、`job`、`none` 五类模型; +- 使用 `type` 作为 Pydantic 判别字段; +- 限制通知级别、消息长度、路由白名单、刷新 Scope 和 Job ID; +- TypeScript 同步为精确的判别联合; +- OpenAPI `PluginCommandResult.effect` 生成 `oneOf` 和 discriminator; +- 保留可序列化性和 64 KiB 总大小限制作为第二层保护。 + +## 12. D-10:必填普通设置没有阻止启用和执行 + +### 原因 + +初始 Runtime 会合并已保存值和默认值,却没有检查 `required` 且没有默认值的普通字段是否仍为空。Secret 已有独立缺失检查,因此测试容易只覆盖 Secret,忽略普通 Settings。 + +### 后果 + +- 配置不完整的 Plugin 仍可启动 MCP Host; +- Command 到插件内部才因缺少字段失败; +- 用户只能看到模糊执行错误,不知道应先补配置; +- 插件启用后删除必填值,后续执行没有再次校验。 + +### 解决思路 + +必填设置既是启用前置条件,也是每次执行的运行时不变量。不能只在保存表单时校验,因为磁盘内容可能变化,启用后的配置也可能被更新。 + +### 解决方案 + +- `runtime_values()` 返回完整有效普通 Settings; +- 缺少必填且无默认值的字段时抛出 `PLUGIN_SETTINGS_REQUIRED`; +- Plugin Enable 前执行一次检查,不启动无效 Host; +- 每次 Command Execute 前重新检查; +- 返回 409 和缺失字段上下文,便于前端引导用户进入设置页; +- 增加“补齐设置后可启用”的完整回归测试。 + +## 13. D-11:MCP 工具 Schema 被发现后丢弃,真实信封未校验 + +### 原因 + +MCP 初始化阶段能够取得工具名称和 `inputSchema`,但 Runtime 记录只保留了工具名。Command 执行时直接发送宿主信封,没有用目标工具的完整 Schema 校验实际数据。 + +这意味着插件只要暴露同名工具就可能通过启用检查,即使它根本不接受 NotesAgent Command 协议。 + +### 后果 + +- 不兼容目标在启用阶段被注册为可执行 Command; +- 错误推迟到远程 MCP Server,返回信息不稳定; +- Context、Settings 或 Secret 结构变化时无法在宿主边界发现漂移; +- 前端看到可用命令,执行后才得到 502; +- 禁用或重启后若 Schema 缓存不清理,还可能使用过期契约。 + +### 解决思路 + +发现阶段保留完整目标 Schema;启用阶段只检查最低协议标记;执行阶段再用真实数据验证全部约束。Schema 生命周期必须和 MCP 工具生命周期一致。 + +### 解决方案 + +- Plugin 运行记录增加 MCP Command Schema 映射; +- 禁用、回滚、重启和 Host 不可用时同步清理 Schema; +- 启用时要求目标 Schema 顶层直接声明 `properties._notesagent`,且类型为 `object`; +- 执行前构造完整固定信封; +- 使用官方 Draft 2020-12 Validator 校验真实信封; +- 不匹配时返回 `PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH`,不调用 MCP Server。 + +## 14. D-12:启用探测和回归测试存在假阳性或假阴性 + +### 原因 + +审阅期间先后暴露了三类测试方法问题: + +1. 启用阶段曾用空 arguments、Context、Settings 和 Secret 伪造信封,以判断目标 Schema 是否兼容。合法 Schema 如果要求真实业务字段,会被错误拒绝; +2. 为避免空数据误判,曾尝试加入自定义 JSON Schema 可满足性求解,但该近似实现无法正确覆盖 `not`、`oneOf` 等完整 Draft 2020-12 语义; +3. MCP Secret Fixture 即使没有收到 `api_key` 也会返回成功,测试只验证了命令成功,没有证明 Secret 真正到达服务端。 + +最后还发现空 Echo 消息会构造 `notification`,但新的通知 Contract 要求消息非空,导致合法空输入被包装为 502。 + +### 后果 + +- 合法插件可能在启用阶段被拒绝,形成假阴性; +- 不兼容 Schema 可能被自定义求解器放过,形成假阳性; +- Secret 传递链路回归后测试仍显示通过; +- Contract 收紧后,旧 Fixture 的边界值会产生新的运行时错误; +- 测试数量增加,却没有真正覆盖需要证明的安全事实。 + +### 解决思路 + +启用阶段只做稳定且明确的协议结构检查,完整 Schema 语义交给官方 Validator 和真实执行数据。测试必须让目标事实缺失时明确失败,而不是通过返回值间接猜测。 + +### 解决方案 + +- 取消空业务数据 Probe; +- 将启用门槛缩小为直接 `_notesagent: { type: object }` 协议标记; +- 允许 `$ref`、`$dynamicRef`、`allOf`、`anyOf`、`oneOf` 和 `not` 等约束出现在 `_notesagent` 内部; +- 完全移除自定义 Schema 可满足性求解器; +- 实际执行统一交给官方 Draft 2020-12 Validator; +- MCP Fixture 未收到声明的 Secret 时主动返回 MCP 错误,但永不返回明文; +- 空 Echo 返回 `none` Effect,非空 Echo 返回 `notification`; +- 为协议标记、真实信封、Secret 到达、空 Echo 和恶意 Effect 分别增加回归测试。 + +## 15. 验证方法与结果 + +本分支最终执行以下验证: + +```powershell +cd backend +uv lock --check +uv run pytest + +cd ../frontend +pnpm test -- --run +pnpm type-check +pnpm build + +cd .. +git diff --check +``` + +结果: + +```text +backend: 136 passed +frontend: 29 passed +TypeScript type-check passed +frontend production build passed +uv lock --check passed +git diff --check passed +``` + +后端测试在 Windows 下仍有既有 `.pytest_cache` 权限警告,不影响测试结果。前端生产构建仍有既有大 Chunk 提示,不影响本次功能正确性和合并结论。 + +新增或强化的关键测试包括: + +- Plugin Secret 公共 API、Provider Resolver 和 Command Resolver 三层隔离; +- 清单空值、重复项、无效权限、非有限边界和非法 Schema; +- MCP Command 与 Agent Tool 隔离; +- 固定信封中的 Context、Settings 和声明式 Secret; +- 外部 Schema 引用拒绝和嵌套 `$id` 资源作用域; +- Secret 定长引用、篡改阻断、删除回滚和卸载原子性; +- 五类 Effect 的合法 Payload 和恶意 Payload 拒绝; +- 必填普通设置对 Enable 和 Execute 的双重门禁; +- MCP 实际信封校验、Secret 到达证明和空 Echo 行为。 + +## 16. 预防措施 + +- Plugin 包、MCP Schema 和 MCP 返回值一律按不可信输入处理; +- Secret 安全检查必须覆盖写入、读取、删除、解析、传输和审计全链路; +- 保留命名空间需要在公共 API 和内部 Resolver 两侧同时阻断; +- 多存储更新必须设计原子提交或补偿回滚,并测试中途失败; +- 不自行实现通用 JSON Schema 求解器,标准语义交给官方 Validator; +- 启用阶段只检查静态协议能力,不使用伪业务数据推导可执行性; +- Agent Tool 和用户触发的 Plugin Command 必须保持独立注册和权限边界; +- Pydantic Contract、OpenAPI、TypeScript DTO 和测试 Fixture 在同一提交中同步; +- 回归测试应直接证明目标事实,例如“Secret 确实到达且未落盘”,不能只证明接口返回成功; +- 每次收紧 Contract 后,重新检查空值、默认值、最大值和旧 Fixture 等边界输入。 + +## 17. 当前边界与后续事项 + +本次合并完成的是阶段 D 的宿主协议和开发期运行边界,不等于第三方插件已经具备生产级操作系统隔离。 + +当前仍保留以下后续事项: + +- 阶段 C.5 按规划放在第二阶段功能与测试完成后、第三阶段桌面集成正式构建 Tauri/Rust 沙箱之前; +- 生产环境继续通过配置门禁拒绝未沙箱化的 MCP Host; +- 桌面端将 Plugin Secret 从开发期 Fernet 文件迁移到 Stronghold 或系统 Keychain,保持现有引用和 HTTP Contract; +- 前端已实现命令面板、Plugin 详情命令和动态 Settings / Secret 表单;上下文菜单与 Toolbar 挂载点仍复用现有 Service 继续扩展; +- Command 审计当前是 500 条有界内存队列,长期审计持久化需在后续阶段单独设计; +- 前端大 Chunk 应通过路由和 Markdown 依赖拆包处理,不与本次 Extension Contract 修改混合。 + +## 18. 复盘结论 + +本次最重要的经验是:插件系统的正确性不能只按“命令是否执行成功”判断。真正需要审阅的是数据从清单进入注册表、从 Settings 进入运行时、从 Secret Store 进入执行器、从宿主信封进入 MCP,以及从 MCP Effect 返回前端的每一道边界。 + +连续审阅避免了跨命名空间 Secret 访问、非事务删除、外部 Schema I/O、MCP 伪兼容和任意 Effect 等问题进入 `main`。最终实现把每个边界落到明确 Contract、稳定错误码和可失败的回归测试上,为下一阶段前端集成和桌面安全沙箱提供了可复用基础。 diff --git a/docs/retrospectives/前端合并审阅问题与修复复盘.md b/docs/retrospectives/前端合并审阅问题与修复复盘.md index 2643ef3..27ed505 100644 --- a/docs/retrospectives/前端合并审阅问题与修复复盘.md +++ b/docs/retrospectives/前端合并审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 涉及提交:`f9efc4f`,合并提交 `c6c28e4`。 > 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。 -> 2026-08-30 状态补充:在本文两轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入,以及智能体页面汉化。当前前端回归基线为 14 项测试和生产构建通过。 +> 2026-09-02 状态补充:在本文多轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入、智能体页面汉化,以及 Plugin Command/Settings Service。当前前端回归基线为 29 项测试,TypeScript 类型检查和生产构建通过。 ## 1. 结论 @@ -281,4 +281,4 @@ git diff --check passed 第三轮交互完善继续处理了文件切换、Markdown 选区格式、代码块默认状态、亮暗主题对比度和浮动工具栏失效问题。Provider 设置页增加 OpenAI、DeepSeek、Ollama 预设与模型自动发现,API Key 改为提交给后端加密保存,不进入 Pinia 或 Local Storage。智能体页面的运行状态、事件、工具、权限及导航文案已完成中文化,同时保留技术 ID 便于排障。 -该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;当前结果为前端 14 项、后端 71 项测试通过,生产构建通过。 +该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;该轮当时结果为前端 14 项、后端 71 项测试通过,生产构建通过。最新全仓基线见本文开头的状态补充。 diff --git a/docs/retrospectives/后端全面审阅问题与修复复盘.md b/docs/retrospectives/后端全面审阅问题与修复复盘.md index 59fa47e..eef08de 100644 --- a/docs/retrospectives/后端全面审阅问题与修复复盘.md +++ b/docs/retrospectives/后端全面审阅问题与修复复盘.md @@ -4,7 +4,7 @@ > 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 > 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。 -> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。 +> 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化、stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 136 项测试通过。 ## 1. 审阅结论 diff --git a/frontend/src/components/common/CommandPalette.spec.ts b/frontend/src/components/common/CommandPalette.spec.ts new file mode 100644 index 0000000..114cd32 --- /dev/null +++ b/frontend/src/components/common/CommandPalette.spec.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as pluginService from '@/services/pluginService' +import { useEditorStore } from '@/stores/editor' +import { useWorkspaceStore } from '@/stores/workspace' +import CommandPalette from './CommandPalette.vue' + +vi.mock('@/services/pluginService', async (loadOriginal) => { + const original = await loadOriginal() + return { ...original, listPluginCommands: vi.fn(), executePluginCommand: vi.fn() } +}) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(pluginService.listPluginCommands).mockResolvedValue([{ + command_id: 'demo.selection', + plugin_id: 'demo', + title: '处理选区', + description: '', + icon: null, + locations: ['command_palette'], + when: ['workspace.has_vault', 'editor.has_note', 'editor.has_selection'], + parameters: { type: 'object', properties: {}, additionalProperties: false }, + enabled: true, + }]) + vi.mocked(pluginService.executePluginCommand).mockResolvedValue({ + command_id: 'demo.selection', + status: 'completed', + effect: { type: 'notification', payload: { level: 'success', message: '完成' } }, + }) +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('CommandPalette Plugin Command', () => { + it('filters by when context and sends stable backend identities plus the captured selection', async () => { + const workspace = useWorkspaceStore() + workspace.hasVault = true + workspace.vaultId = 'vault-default' + const editor = useEditorStore() + editor.currentNoteId = 'note-1' + editor.currentFilePath = '/note.md' + + vi.spyOn(window, 'getSelection').mockReturnValue({ + toString: () => 'selected text', + } as Selection) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }) + await router.push('/') + const wrapper = mount(CommandPalette, { attachTo: document.body, global: { plugins: [router] } }) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true })) + await flushPromises() + const command = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('处理选区')) + expect(command).toBeTruthy() + command!.click() + await flushPromises() + + expect(pluginService.executePluginCommand).toHaveBeenCalledWith('demo.selection', {}, { + vault_id: 'vault-default', + note_id: 'note-1', + file_path: '/note.md', + selection: 'selected text', + }) + expect(document.body.textContent).toContain('完成') + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index 9ab6aa5..b5a99f6 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -5,18 +5,26 @@ import { useEditorStore } from '@/stores/editor' import { useThemeStore } from '@/stores/theme' import { useWorkspaceStore } from '@/stores/workspace' import * as workspaceService from '@/services/workspaceService' +import * as pluginService from '@/services/pluginService' +import type { PluginCommand, PluginCommandEffect } from '@/contracts' +import { usePluginStore } from '@/stores/plugin' const router = useRouter() const editorStore = useEditorStore() const themeStore = useThemeStore() const workspaceStore = useWorkspaceStore() +const pluginStore = usePluginStore() const open = ref(false) const query = ref('') const input = ref(null) +const pluginCommands = ref([]) +const commandError = ref('') +const commandNotice = ref('') +const selectionSnapshot = ref(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } -const commands = computed(() => [ +const builtinCommands = computed(() => [ { id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') }, { id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, { id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, @@ -28,14 +36,37 @@ const commands = computed(() => [ { id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, ]) +const commands = computed(() => [ + ...builtinCommands.value, + ...pluginCommands.value.filter(isPluginCommandAvailable).map((command) => ({ + id: 'plugin:' + command.command_id, + label: command.title, + hint: 'Plugin · ' + command.plugin_id, + run: () => executePluginCommand(command), + })), +]) + +function isPluginCommandAvailable(command: PluginCommand) { + if (!command.enabled) return false + return command.when.every((condition) => { + if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId) + if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId) + if (condition === 'editor.has_selection') return Boolean(selectionSnapshot.value) + return false + }) +} + const filteredCommands = computed(() => { const value = query.value.trim().toLocaleLowerCase() return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value }) function show() { + selectionSnapshot.value = window.getSelection()?.toString() || null open.value = true query.value = '' + commandError.value = '' + void loadPluginCommands() void nextTick(() => input.value?.focus()) } @@ -44,7 +75,11 @@ function hide() { open.value = false } async function execute(command: Command | undefined) { if (!command) return hide() - await command.run() + try { + await command.run() + } catch (error) { + commandNotice.value = error instanceof Error ? error.message : '命令执行失败' + } } async function createNote() { @@ -58,6 +93,55 @@ async function createNote() { await router.push('/workspace') } +async function loadPluginCommands() { + try { + pluginCommands.value = await pluginService.listPluginCommands('command_palette') + } catch (error) { + commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败' + } +} + +function hasRequiredArguments(command: PluginCommand) { + return Array.isArray(command.parameters.required) && command.parameters.required.length > 0 +} + +async function executePluginCommand(command: PluginCommand) { + if (hasRequiredArguments(command)) { + pluginStore.selectPlugin(command.plugin_id) + await router.push('/extensions/plugins') + commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。' + return + } + const result = await pluginService.executePluginCommand(command.command_id, {}, { + vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null, + note_id: editorStore.currentNoteId, + file_path: editorStore.currentFilePath, + selection: selectionSnapshot.value, + }) + await applyPluginEffect(result.effect) +} + +async function applyPluginEffect(effect: PluginCommandEffect) { + if (effect.type === 'notification') { commandNotice.value = effect.payload.message; return } + if (effect.type === 'navigate') { + const routes: Record = { + 'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat', + agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills', + plugins: '/extensions/plugins', themes: '/themes', settings: '/settings', + } + await router.push(routes[effect.payload.route]) + return + } + if (effect.type === 'refresh') { + if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins() + if (effect.payload.scope === 'commands') await loadPluginCommands() + commandNotice.value = '相关数据已刷新。' + return + } + if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return } + commandNotice.value = 'Plugin 命令执行完成。' +} + function handleKeydown(event: KeyboardEvent) { if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') { event.preventDefault() @@ -72,10 +156,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))