fix(extension): 收紧插件命令运行时契约

This commit is contained in:
2026-09-02 20:03:39 +08:00
parent eb3464b522
commit d39ae727c1
10 changed files with 466 additions and 43 deletions
+79 -5
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr from pydantic import BaseModel, ConfigDict, Field, SecretStr
@@ -520,15 +520,89 @@ class PluginCommandExecuteRequest(Contract):
context: PluginCommandContext = Field(default_factory=PluginCommandContext) context: PluginCommandContext = Field(default_factory=PluginCommandContext)
class PluginCommandEffect(Contract): class PluginNotificationEffectPayload(Contract):
type: Literal["none", "notification", "navigate", "refresh", "job"] = "none" level: Literal["info", "success", "warning", "error"] = "info"
payload: dict[str, Any] = Field(default_factory=dict) 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): class PluginCommandResult(Contract):
command_id: str command_id: str
status: Literal["completed"] = "completed" status: Literal["completed"] = "completed"
effect: PluginCommandEffect = Field(default_factory=PluginCommandEffect) effect: PluginCommandEffect = Field(default_factory=PluginNoEffect)
class PluginSettingType(str, Enum): class PluginSettingType(str, Enum):
+24 -1
View File
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings from app.config import get_settings
from app.contracts import ( from app.contracts import (
PLUGIN_COMMAND_EFFECT_TYPES,
PluginCommand, PluginCommand,
PluginCommandContext, PluginCommandContext,
PluginCommandEffect, PluginCommandEffect,
@@ -248,7 +249,7 @@ class CommandRegistry:
) )
self._record_audit(registered, started_at, error.code) self._record_audit(registered, started_at, error.code)
raise error from exc raise error from exc
if not isinstance(effect, PluginCommandEffect): if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES):
error = ExtensionError( error = ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID", "PLUGIN_COMMAND_RESULT_INVALID",
"Plugin command returned an invalid effect.", "Plugin command returned an invalid effect.",
@@ -362,6 +363,28 @@ class PluginSettingsStore:
secrets=secrets, 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( def update(
self, self,
plugin_id: str, plugin_id: str,
+109 -18
View File
@@ -9,8 +9,18 @@ from uuid import uuid4
import yaml import yaml
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError from jsonschema.exceptions import (
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model 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.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS from app.agent.permissions import KNOWN_PERMISSIONS
@@ -20,6 +30,8 @@ from app.contracts import (
PluginCommand, PluginCommand,
PluginCommandContext, PluginCommandContext,
PluginCommandEffect, PluginCommandEffect,
PluginNoEffect,
PluginNotificationEffect,
PluginCommandLocation, PluginCommandLocation,
PluginCommandResult, PluginCommandResult,
PluginManifest, PluginManifest,
@@ -258,15 +270,15 @@ class DeclarativePluginHost:
if handler == "echo": if handler == "echo":
message = str(arguments.get("message", context.get("selection", ""))) message = str(arguments.get("message", context.get("selection", "")))
return PluginCommandEffect( if not message:
type="notification", return PluginNoEffect()
return PluginNotificationEffect(
payload={"level": "info", "message": message}, payload={"level": "info", "message": message},
) )
if handler == "uppercase_selection": if handler == "uppercase_selection":
text = str(arguments.get("text", context.get("selection", ""))) text = str(arguments.get("text", context.get("selection", "")))
limit = int(settings.get("result_limit", 100)) limit = int(settings.get("result_limit", 100))
return PluginCommandEffect( return PluginNotificationEffect(
type="notification",
payload={"level": "success", "message": text[:limit].upper()}, payload={"level": "success", "message": text[:limit].upper()},
) )
raise ExtensionError( raise ExtensionError(
@@ -284,6 +296,7 @@ class _PluginRecord:
registered_tools: list[str] registered_tools: list[str]
registered_commands: list[str] registered_commands: list[str]
mcp_remote_names: dict[str, str] mcp_remote_names: dict[str, str]
mcp_command_schemas: dict[str, dict[str, Any]]
class PluginRuntime: class PluginRuntime:
@@ -448,6 +461,7 @@ class PluginRuntime:
registered_tools=[], registered_tools=[],
registered_commands=[], registered_commands=[],
mcp_remote_names={}, mcp_remote_names={},
mcp_command_schemas={},
) )
self._records[manifest.plugin_id] = record self._records[manifest.plugin_id] = record
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
@@ -488,6 +502,8 @@ class PluginRuntime:
status_code=403, status_code=403,
details={"plugin_id": plugin_id}, 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) declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)] conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts: if conflicts:
@@ -528,6 +544,18 @@ class PluginRuntime:
self._register_mcp_tool(record, item) self._register_mcp_tool(record, item)
else: else:
record.mcp_remote_names[item.definition.name] = item.remote_name 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: else:
for spec in record.tools: for spec in record.tools:
arguments_model = _arguments_model(spec) arguments_model = _arguments_model(spec)
@@ -570,10 +598,10 @@ class PluginRuntime:
details={"command_id": _spec.command_id}, details={"command_id": _spec.command_id},
) )
settings = ( settings = (
self.settings.get( self.settings.runtime_values(
_record.plugin.manifest.plugin_id, _record.plugin.manifest.plugin_id,
_record.settings_definition, _record.settings_definition,
).values )
if _record.settings_definition is not None if _record.settings_definition is not None
else {} else {}
) )
@@ -624,19 +652,23 @@ class PluginRuntime:
for key in _spec.secrets for key in _spec.secrets
if (value := resolve_secret(key)) is not None 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: try:
effect = await self.mcp.call_tool( effect = await self.mcp.call_tool(
_record.plugin.manifest.plugin_id, _record.plugin.manifest.plugin_id,
remote_name, remote_name,
{ envelope,
"_notesagent": {
"command_id": _spec.command_id,
"arguments": arguments,
"context": context,
"settings": settings,
"secrets": secret_values,
}
},
request_id=f"command:{uuid4().hex}", request_id=f"command:{uuid4().hex}",
) )
except ToolExecutionError as exc: except ToolExecutionError as exc:
@@ -647,7 +679,7 @@ class PluginRuntime:
details={"command_id": _spec.command_id}, details={"command_id": _spec.command_id},
) from exc ) from exc
try: try:
return PluginCommandEffect.model_validate(effect) return TypeAdapter(PluginCommandEffect).validate_python(effect)
except ValidationError as exc: except ValidationError as exc:
raise ExtensionError( raise ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID", "PLUGIN_COMMAND_RESULT_INVALID",
@@ -675,6 +707,7 @@ class PluginRuntime:
self.commands.unregister(command_id) self.commands.unregister(command_id)
record.registered_commands.clear() record.registered_commands.clear()
record.mcp_remote_names.clear() record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id) self.mcp.stop(plugin_id)
record.plugin.status = PluginStatus.error record.plugin.status = PluginStatus.error
record.plugin.error_message = _safe_extension_message(exc) record.plugin.error_message = _safe_extension_message(exc)
@@ -736,6 +769,7 @@ class PluginRuntime:
self.commands.unregister(command_id) self.commands.unregister(command_id)
record.registered_commands.clear() record.registered_commands.clear()
record.mcp_remote_names.clear() record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
if record.plugin.manifest.backend.type == "mcp": if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id) self.mcp.stop(plugin_id)
record.plugin.enabled = False record.plugin.enabled = False
@@ -814,6 +848,7 @@ class PluginRuntime:
self.commands.unregister(command_id) self.commands.unregister(command_id)
record.registered_commands.clear() record.registered_commands.clear()
record.mcp_remote_names.clear() record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id) self.mcp.stop(plugin_id)
record.plugin.enabled = False record.plugin.enabled = False
record.plugin.status = PluginStatus.installed record.plugin.status = PluginStatus.installed
@@ -878,6 +913,7 @@ class PluginRuntime:
self.commands.unregister(command_id) self.commands.unregister(command_id)
record.registered_commands.clear() record.registered_commands.clear()
record.mcp_remote_names.clear() record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
record.plugin.enabled = False record.plugin.enabled = False
record.plugin.status = PluginStatus.error record.plugin.status = PluginStatus.error
record.plugin.error_message = message record.plugin.error_message = message
@@ -1030,6 +1066,61 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
return _arguments_model_from_schema(spec.name, schema) 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( def _arguments_model_from_schema(
tool_name: str, schema: dict[str, Any] tool_name: str, schema: dict[str, Any]
) -> type[BaseModel]: ) -> type[BaseModel]:
@@ -73,6 +73,15 @@ def call_tool(request_id: int, params: dict[str, Any]) -> None:
context = envelope.get("context") or {} context = envelope.get("context") or {}
settings = envelope.get("settings") or {} settings = envelope.get("settings") or {}
secrets = envelope.get("secrets") 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 = command_arguments.get("message") or context.get("selection") or ""
message = f"{settings.get('message_prefix', '')}{message}" message = f"{settings.get('message_prefix', '')}{message}"
respond( respond(
@@ -84,7 +93,6 @@ def call_tool(request_id: int, params: dict[str, Any]) -> None:
"payload": { "payload": {
"level": "success", "level": "success",
"message": str(message), "message": str(message),
"secret_configured": isinstance(secrets.get("api_key"), str),
}, },
}, },
"isError": False, "isError": False,
+119 -3
View File
@@ -17,7 +17,10 @@ from app.contracts import (
) )
from app.extensions import ExtensionError from app.extensions import ExtensionError
from app.extensions.mcp import McpStdioClient 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.services import note_service
from app.config import BACKEND_DIR, get_settings 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.type == "notification"
assert result.effect.payload == { assert result.effect.payload.model_dump() == {
"level": "success", "level": "success",
"message": "Fixture: 来自选区", "message": "Fixture: 来自选区",
"secret_configured": True,
} }
assert "mcp-command-secret" not in repr( assert "mcp-command-secret" not in repr(
mcp_container.plugins.commands.audit_events() mcp_container.plugins.commands.audit_events()
@@ -440,6 +442,120 @@ def test_mcp_command_target_receives_scoped_context_and_declared_secret(
run(scenario()) 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: def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None: async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture") mcp_container.plugins.enable("mcp-fixture")
+89 -3
View File
@@ -3,6 +3,7 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from pydantic import TypeAdapter, ValidationError
from app.agent import ToolRegistry from app.agent import ToolRegistry
from app.config import BACKEND_DIR, get_settings from app.config import BACKEND_DIR, get_settings
@@ -10,6 +11,7 @@ from app.container import build_container
from app.contracts import ( from app.contracts import (
PluginCommandContext, PluginCommandContext,
PluginCommandEffect, PluginCommandEffect,
PluginNoEffect,
PluginSettingType, PluginSettingType,
) )
from app.extensions import ExtensionError, PluginRuntime 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.status == "completed"
assert result.effect.type == "notification" 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: 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, handler, arguments, context, settings, resolve_secret
): ):
self.context = context self.context = context
return PluginCommandEffect(type="none") return PluginNoEffect()
host = CapturingHost() host = CapturingHost()
runtime = PluginRuntime(ToolRegistry(), host=host) runtime = PluginRuntime(ToolRegistry(), host=host)
@@ -146,7 +181,7 @@ def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None:
resolve_secret("undeclared") resolve_secret("undeclared")
except ExtensionError as exc: except ExtensionError as exc:
self.denied_code = exc.code self.denied_code = exc.code
return PluginCommandEffect(type="none") return PluginNoEffect()
host = SecretHost() host = SecretHost()
runtime = PluginRuntime(ToolRegistry(), host=host) 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 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: def test_secret_roundtrip_never_enters_plain_settings_storage() -> None:
container = build_container() container = build_container()
plaintext = "stage-d-secret-value" plaintext = "stage-d-secret-value"
@@ -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。 当前宿主只注册已启用且已满足权限授权的 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` DTOSecret 明文也不会并入普通 Settings 字典。 需要 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` DTOSecret 明文也不会并入普通 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 ### 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 使用: Secret 使用:
@@ -656,9 +656,11 @@ PLUGIN_COMMAND_TIMEOUT
PLUGIN_COMMAND_EXECUTION_FAILED PLUGIN_COMMAND_EXECUTION_FAILED
PLUGIN_COMMAND_RESULT_INVALID PLUGIN_COMMAND_RESULT_INVALID
PLUGIN_COMMAND_RESULT_TOO_LARGE PLUGIN_COMMAND_RESULT_TOO_LARGE
PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH
PLUGIN_SETTINGS_SCHEMA_INVALID PLUGIN_SETTINGS_SCHEMA_INVALID
PLUGIN_SETTINGS_VERSION_CONFLICT PLUGIN_SETTINGS_VERSION_CONFLICT
PLUGIN_SETTINGS_FIELD_INVALID PLUGIN_SETTINGS_FIELD_INVALID
PLUGIN_SETTINGS_REQUIRED
PLUGIN_SECRET_FIELD_NOT_FOUND PLUGIN_SECRET_FIELD_NOT_FOUND
PLUGIN_SECRET_ACCESS_DENIED PLUGIN_SECRET_ACCESS_DENIED
PLUGIN_SECRET_REQUIRED PLUGIN_SECRET_REQUIRED
@@ -40,14 +40,14 @@ Plugin 在 `plugin.yaml` 的 `contributes.commands` 与 `contributes.settings_se
3. 根据 `when` 检查必要上下文; 3. 根据 `when` 检查必要上下文;
4. 仅向执行器传递声明过的 Context 字段; 4. 仅向执行器传递声明过的 Context 字段;
5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target 5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target
6. 校验 effect 类型、可序列化性和 64 KiB 大小上限; 6. effect 类型校验专属 payload、可序列化性和 64 KiB 大小上限;
7. 返回统一 `PluginCommandResult` 7. 返回统一 `PluginCommandResult`
首批 effect 为 `none``notification``navigate``refresh``job`。前端不得把 effect 当作任意代码执行。 首批 effect 为 `none``notification``navigate``refresh``job`后端分别限制通知级别与消息、宿主路由名、刷新范围和 Job IDPydantic 与 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 均不进入审计事件。 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 和声明过的 SecretMCP 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 按各自作用域解析,无法解析的引用在注册阶段返回稳定错误。 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 时同时删除普通设置命名空间和对应加密凭据。 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.<sha256(...)>`,引用不匹配即按损坏存储拒绝处理,不能借由篡改 `settings.json` 读取或删除 Provider 等其他命名空间的凭据。删除单个 Secret 或卸载 Plugin 时先原子更新 Settings 引用,再删除加密凭据;底层删除失败会恢复原引用。多 Secret 卸载使用一次凭据表原子替换,避免分批删除部分删除。 读取持久化引用时,宿主会重新计算并核对 `plugin.<sha256(...)>`,引用不匹配即按损坏存储拒绝处理,不能借由篡改 `settings.json` 读取或删除 Provider 等其他命名空间的凭据。删除单个 Secret 或卸载 Plugin 时先原子更新 Settings 引用,再删除加密凭据;底层删除失败会恢复原引用。多 Secret 卸载使用一次凭据表原子替换,避免分批删除部分删除。
开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 KeychainHTTP Contract 无需因此改变。 开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 KeychainHTTP Contract 无需因此改变。
@@ -90,7 +92,7 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret
- Command 未注册、冲突、参数或 Context 无效; - Command 未注册、冲突、参数或 Context 无效;
- 执行超时、执行器异常、effect 无效或过大; - 执行超时、执行器异常、effect 无效或过大;
- Settings Schema 无效、版本冲突、字段类型/边界错误; - Settings Schema 无效、版本冲突、字段类型/边界错误或运行时必填值缺失
- Secret 字段不存在、空 Secret、凭据存储异常; - Secret 字段不存在、空 Secret、凭据存储异常;
- Settings JSON 根结构或 Plugin 命名空间损坏。 - Settings JSON 根结构或 Plugin 命名空间损坏。
+24 -4
View File
@@ -292,10 +292,30 @@ export interface PluginCommandContext {
selection?: string | null selection?: string | null
} }
export interface PluginCommandEffect { export type PluginCommandEffect =
type: 'none' | 'notification' | 'navigate' | 'refresh' | 'job' | { type: 'none'; payload: Record<string, never> }
payload: Record<string, unknown> | {
} 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 { export interface PluginCommandResult {
command_id: string command_id: string
+2 -1
View File
@@ -26,7 +26,7 @@ describe('pluginService contribution adapter', () => {
.mockResolvedValueOnce(jsonResponse({ .mockResolvedValueOnce(jsonResponse({
command_id: 'text-tools.uppercase-selection', command_id: 'text-tools.uppercase-selection',
status: 'completed', status: 'completed',
effect: { type: 'notification', payload: { message: 'HELLO' } }, effect: { type: 'notification', payload: { level: 'success', message: 'HELLO' } },
})) }))
const commands = await pluginService.listPluginCommands('command_palette') 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') 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(result.effect.payload.message).toBe('HELLO')
expect(fetchMock.mock.calls[0][0]).toBe( expect(fetchMock.mock.calls[0][0]).toBe(
'/api/plugin-contributions/commands?location=command_palette', '/api/plugin-contributions/commands?location=command_palette',