fix(extension): 收紧插件命令运行时契约
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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