From d39ae727c162c6285656121635b10788b9212834 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 2 Sep 2026 20:03:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(extension):=20=E6=94=B6=E7=B4=A7=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=91=BD=E4=BB=A4=E8=BF=90=E8=A1=8C=E6=97=B6=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/contracts.py | 84 +++++++++++- backend/app/extensions/contributions.py | 25 +++- backend/app/extensions/runtime.py | 127 +++++++++++++++--- .../extensions/fixtures/mcp-echo/server.py | 10 +- backend/tests/test_extension_core.py | 122 ++++++++++++++++- backend/tests/test_plugin_contributions.py | 92 ++++++++++++- docs/contracts/第二阶段接口契约-开发版.md | 8 +- .../Plugin-Command与Settings开发说明.md | 10 +- frontend/src/contracts/index.ts | 28 +++- frontend/src/services/pluginService.spec.ts | 3 +- 10 files changed, 466 insertions(+), 43 deletions(-) diff --git a/backend/app/contracts.py b/backend/app/contracts.py index e0d4869..f95e890 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 @@ -520,15 +520,89 @@ class PluginCommandExecuteRequest(Contract): context: PluginCommandContext = Field(default_factory=PluginCommandContext) -class PluginCommandEffect(Contract): - type: Literal["none", "notification", "navigate", "refresh", "job"] = "none" - payload: dict[str, Any] = Field(default_factory=dict) +class 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=PluginCommandEffect) + effect: PluginCommandEffect = Field(default_factory=PluginNoEffect) class PluginSettingType(str, Enum): diff --git a/backend/app/extensions/contributions.py b/backend/app/extensions/contributions.py index 7be209a..5402f42 100644 --- a/backend/app/extensions/contributions.py +++ b/backend/app/extensions/contributions.py @@ -22,6 +22,7 @@ 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, @@ -248,7 +249,7 @@ class CommandRegistry: ) self._record_audit(registered, started_at, error.code) raise error from exc - if not isinstance(effect, PluginCommandEffect): + if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES): error = ExtensionError( "PLUGIN_COMMAND_RESULT_INVALID", "Plugin command returned an invalid effect.", @@ -362,6 +363,28 @@ class PluginSettingsStore: 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, diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 6a5e918..960554e 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -9,8 +9,18 @@ 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, ToolExecutionError, ToolRegistry from app.agent.permissions import KNOWN_PERMISSIONS @@ -20,6 +30,8 @@ from app.contracts import ( PluginCommand, PluginCommandContext, PluginCommandEffect, + PluginNoEffect, + PluginNotificationEffect, PluginCommandLocation, PluginCommandResult, PluginManifest, @@ -258,15 +270,15 @@ class DeclarativePluginHost: if handler == "echo": message = str(arguments.get("message", context.get("selection", ""))) - return PluginCommandEffect( - type="notification", + 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 PluginCommandEffect( - type="notification", + return PluginNotificationEffect( payload={"level": "success", "message": text[:limit].upper()}, ) raise ExtensionError( @@ -284,6 +296,7 @@ class _PluginRecord: registered_tools: list[str] registered_commands: list[str] mcp_remote_names: dict[str, str] + mcp_command_schemas: dict[str, dict[str, Any]] class PluginRuntime: @@ -448,6 +461,7 @@ class PluginRuntime: registered_tools=[], registered_commands=[], mcp_remote_names={}, + mcp_command_schemas={}, ) self._records[manifest.plugin_id] = record return record.plugin.model_copy(deep=True) @@ -488,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: @@ -528,6 +544,18 @@ class PluginRuntime: 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) @@ -570,10 +598,10 @@ class PluginRuntime: details={"command_id": _spec.command_id}, ) settings = ( - self.settings.get( + self.settings.runtime_values( _record.plugin.manifest.plugin_id, _record.settings_definition, - ).values + ) if _record.settings_definition is not None else {} ) @@ -624,19 +652,23 @@ class PluginRuntime: 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, - { - "_notesagent": { - "command_id": _spec.command_id, - "arguments": arguments, - "context": context, - "settings": settings, - "secrets": secret_values, - } - }, + envelope, request_id=f"command:{uuid4().hex}", ) except ToolExecutionError as exc: @@ -647,7 +679,7 @@ class PluginRuntime: details={"command_id": _spec.command_id}, ) from exc try: - return PluginCommandEffect.model_validate(effect) + return TypeAdapter(PluginCommandEffect).validate_python(effect) except ValidationError as exc: raise ExtensionError( "PLUGIN_COMMAND_RESULT_INVALID", @@ -675,6 +707,7 @@ class PluginRuntime: 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) @@ -736,6 +769,7 @@ class PluginRuntime: 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 @@ -814,6 +848,7 @@ class PluginRuntime: 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 @@ -878,6 +913,7 @@ class PluginRuntime: 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 @@ -1030,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]: diff --git a/backend/extensions/fixtures/mcp-echo/server.py b/backend/extensions/fixtures/mcp-echo/server.py index f9d480f..fed5e55 100644 --- a/backend/extensions/fixtures/mcp-echo/server.py +++ b/backend/extensions/fixtures/mcp-echo/server.py @@ -73,6 +73,15 @@ def call_tool(request_id: int, params: dict[str, Any]) -> None: 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( @@ -84,7 +93,6 @@ def call_tool(request_id: int, params: dict[str, Any]) -> None: "payload": { "level": "success", "message": str(message), - "secret_configured": isinstance(secrets.get("api_key"), str), }, }, "isError": False, diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index b96f648..bdffc8c 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -17,7 +17,10 @@ from app.contracts import ( ) 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 @@ -428,10 +431,9 @@ def test_mcp_command_target_receives_scoped_context_and_declared_secret( ) assert result.effect.type == "notification" - assert result.effect.payload == { + assert result.effect.payload.model_dump() == { "level": "success", "message": "Fixture: 来自选区", - "secret_configured": True, } assert "mcp-command-secret" not in repr( mcp_container.plugins.commands.audit_events() @@ -440,6 +442,120 @@ def test_mcp_command_target_receives_scoped_context_and_declared_secret( 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") diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py index def7204..1235d6f 100644 --- a/backend/tests/test_plugin_contributions.py +++ b/backend/tests/test_plugin_contributions.py @@ -3,6 +3,7 @@ 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 @@ -10,6 +11,7 @@ from app.container import build_container from app.contracts import ( PluginCommandContext, PluginCommandEffect, + PluginNoEffect, PluginSettingType, ) from app.extensions import ExtensionError, PluginRuntime @@ -70,7 +72,40 @@ def test_command_executes_with_scoped_context_and_settings() -> None: assert result.status == "completed" assert result.effect.type == "notification" - assert result.effect.payload == {"level": "success", "message": "ABCD"} + 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: @@ -112,7 +147,7 @@ def test_command_only_receives_declared_context() -> None: self, handler, arguments, context, settings, resolve_secret ): self.context = context - return PluginCommandEffect(type="none") + return PluginNoEffect() host = CapturingHost() runtime = PluginRuntime(ToolRegistry(), host=host) @@ -146,7 +181,7 @@ def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None: resolve_secret("undeclared") except ExtensionError as exc: self.denied_code = exc.code - return PluginCommandEffect(type="none") + return PluginNoEffect() host = SecretHost() runtime = PluginRuntime(ToolRegistry(), host=host) @@ -248,6 +283,57 @@ def test_settings_update_validates_version_type_bounds_and_secret_boundary() -> 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" diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 8a64ace..00d3adb 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -540,13 +540,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。Command 与 Tool Schema 仅允许 `#...` 文档内引用,任何通过 `$ref` 或 `$dynamicRef` 指向文件、HTTP 或其他外部资源的 Schema 都会在注册前被拒绝。文档内引用遵循 Draft 2020-12 的嵌套 `$id` 与 Anchor 资源作用域,不能解析的引用不得进入运行时。 +`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 @@ -604,7 +604,7 @@ Number 字段的 `minimum` 和 `maximum` 必须是有限数值;`NaN`、正无 } ``` -该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。 +该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。没有默认值的必填普通字段必须先通过该接口配置;否则 Plugin Enable 和 Command Execute 返回 `PLUGIN_SETTINGS_REQUIRED`,MCP 或内部 handler 不会收到残缺配置。 Secret 使用: @@ -656,9 +656,11 @@ 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 diff --git a/docs/development/Plugin-Command与Settings开发说明.md b/docs/development/Plugin-Command与Settings开发说明.md index e2f755c..1285b16 100644 --- a/docs/development/Plugin-Command与Settings开发说明.md +++ b/docs/development/Plugin-Command与Settings开发说明.md @@ -40,14 +40,14 @@ Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_se 3. 根据 `when` 检查必要上下文; 4. 仅向执行器传递声明过的 Context 字段; 5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target; -6. 校验 effect 类型、可序列化性和 64 KiB 大小上限; +6. 按 effect 类型校验专属 payload、可序列化性和 64 KiB 大小上限; 7. 返回统一 `PluginCommandResult`。 -首批 effect 为 `none`、`notification`、`navigate`、`refresh` 和 `job`。前端不得把 effect 当作任意代码执行。 +首批 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;MCP Server 必须返回结构化的白名单 effect。远程原始错误不直接透传给 HTTP 调用方。插件仍不能把模块路径或 Shell 字符串作为执行器。 +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 按各自作用域解析,无法解析的引用在注册阶段返回稳定错误。 @@ -67,6 +67,8 @@ APP_DATA_DIR/plugins/settings.json 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 无需因此改变。 @@ -90,7 +92,7 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret - Command 未注册、冲突、参数或 Context 无效; - 执行超时、执行器异常、effect 无效或过大; -- Settings Schema 无效、版本冲突、字段类型/边界错误; +- Settings Schema 无效、版本冲突、字段类型/边界错误或运行时必填值缺失; - Secret 字段不存在、空 Secret、凭据存储异常; - Settings JSON 根结构或 Plugin 命名空间损坏。 diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index b7a5e0a..901cf43 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -292,10 +292,30 @@ export interface PluginCommandContext { selection?: string | null } -export interface PluginCommandEffect { - type: 'none' | 'notification' | 'navigate' | 'refresh' | 'job' - payload: Record -} +export type PluginCommandEffect = + | { type: 'none'; payload: Record } + | { + type: 'notification' + payload: { level: 'info' | 'success' | 'warning' | 'error'; message: string } + } + | { + type: 'navigate' + payload: { + route: + | 'vault-entry' + | 'workspace' + | 'search' + | 'chat' + | 'agent' + | 'tasks' + | 'skills' + | 'plugins' + | 'themes' + | 'settings' + } + } + | { type: 'refresh'; payload: { scope: 'workspace' | 'commands' | 'settings' | 'plugins' } } + | { type: 'job'; payload: { job_id: string } } export interface PluginCommandResult { command_id: string diff --git a/frontend/src/services/pluginService.spec.ts b/frontend/src/services/pluginService.spec.ts index 612779a..8239921 100644 --- a/frontend/src/services/pluginService.spec.ts +++ b/frontend/src/services/pluginService.spec.ts @@ -26,7 +26,7 @@ describe('pluginService contribution adapter', () => { .mockResolvedValueOnce(jsonResponse({ command_id: 'text-tools.uppercase-selection', status: 'completed', - effect: { type: 'notification', payload: { message: 'HELLO' } }, + effect: { type: 'notification', payload: { level: 'success', message: 'HELLO' } }, })) const commands = await pluginService.listPluginCommands('command_palette') @@ -37,6 +37,7 @@ describe('pluginService contribution adapter', () => { ) expect(commands[0].command_id).toBe('text-tools.uppercase-selection') + if (result.effect.type !== 'notification') throw new Error('expected notification effect') expect(result.effect.payload.message).toBe('HELLO') expect(fetchMock.mock.calls[0][0]).toBe( '/api/plugin-contributions/commands?location=command_palette',