fix(extension): 收紧插件命令运行时契约
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user