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