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

This commit is contained in:
2026-09-02 20:03:39 +08:00
parent 09c1bdac21
commit 1218b5cd71
8 changed files with 455 additions and 36 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"
+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',