fix(extension): 强化 MCP 参数与生产运行门禁
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前回归基线为后端 91 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||||
|
|
||||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
当前基线为 91 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||||
|
|
||||||
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|||||||
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
|
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
|
||||||
from app.agent.builtin_tools import register_builtin_tools
|
from app.agent.builtin_tools import register_builtin_tools
|
||||||
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||||
from app.config import BACKEND_DIR
|
from app.config import BACKEND_DIR, get_settings
|
||||||
from app.extensions import PluginRuntime, SkillRuntime
|
from app.extensions import PluginRuntime, SkillRuntime
|
||||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||||
from app.providers.credentials import (
|
from app.providers.credentials import (
|
||||||
@@ -26,6 +26,7 @@ class ApplicationContainer:
|
|||||||
|
|
||||||
|
|
||||||
def build_container() -> ApplicationContainer:
|
def build_container() -> ApplicationContainer:
|
||||||
|
settings = get_settings()
|
||||||
credentials = EncryptedCredentialStore()
|
credentials = EncryptedCredentialStore()
|
||||||
provider_factory = ProviderFactory(
|
provider_factory = ProviderFactory(
|
||||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||||
@@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer:
|
|||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
register_builtin_tools(tools)
|
register_builtin_tools(tools)
|
||||||
|
|
||||||
plugins = PluginRuntime(tools)
|
plugins = PluginRuntime(
|
||||||
|
tools,
|
||||||
|
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
|
||||||
|
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
|
||||||
|
allow_unsandboxed_mcp=settings.environment == "development",
|
||||||
|
)
|
||||||
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
|
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
|
||||||
plugins.enable("text-tools")
|
plugins.enable("text-tools")
|
||||||
|
|
||||||
|
|||||||
@@ -256,10 +256,13 @@ class PluginRuntime:
|
|||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
host: DeclarativePluginHost | None = None,
|
host: DeclarativePluginHost | None = None,
|
||||||
mcp_bridge: McpBridge | None = None,
|
mcp_bridge: McpBridge | None = None,
|
||||||
|
*,
|
||||||
|
allow_unsandboxed_mcp: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.registry = tools
|
self.registry = tools
|
||||||
self.host = host or DeclarativePluginHost()
|
self.host = host or DeclarativePluginHost()
|
||||||
self.mcp = mcp_bridge or McpBridge()
|
self.mcp = mcp_bridge or McpBridge()
|
||||||
|
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
|
||||||
self._records: dict[str, _PluginRecord] = {}
|
self._records: dict[str, _PluginRecord] = {}
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
@@ -346,6 +349,16 @@ class PluginRuntime:
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
details={"plugin_id": plugin_id, "permissions": missing_grants},
|
details={"plugin_id": plugin_id, "permissions": missing_grants},
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
record.plugin.manifest.backend.type == "mcp"
|
||||||
|
and not self.allow_unsandboxed_mcp
|
||||||
|
):
|
||||||
|
raise ExtensionError(
|
||||||
|
"MCP_TRUST_APPROVAL_REQUIRED",
|
||||||
|
"Unsandboxed MCP Hosts are disabled outside development mode.",
|
||||||
|
status_code=403,
|
||||||
|
details={"plugin_id": plugin_id},
|
||||||
|
)
|
||||||
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:
|
||||||
@@ -665,27 +678,10 @@ def _arguments_model_from_schema(
|
|||||||
) -> type[BaseModel]:
|
) -> type[BaseModel]:
|
||||||
if schema.get("type", "object") != "object":
|
if schema.get("type", "object") != "object":
|
||||||
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.")
|
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.")
|
||||||
properties = schema.get("properties", {})
|
|
||||||
required = set(schema.get("required", []))
|
|
||||||
fields: dict[str, tuple[Any, Any]] = {}
|
|
||||||
types = {
|
|
||||||
"string": str,
|
|
||||||
"number": float,
|
|
||||||
"integer": int,
|
|
||||||
"boolean": bool,
|
|
||||||
"array": list[Any],
|
|
||||||
"object": dict[str, Any],
|
|
||||||
}
|
|
||||||
for name, field_schema in properties.items():
|
|
||||||
schema_type = field_schema.get("type")
|
|
||||||
# JSON Schema 允许联合类型数组;复杂类型继续由 Draft Validator
|
|
||||||
# 精确校验,Pydantic 在这里只承担参数载体职责。
|
|
||||||
annotation = types.get(schema_type, Any) if isinstance(schema_type, str) else Any
|
|
||||||
fields[name] = (annotation, ... if name in required else None)
|
|
||||||
model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
|
model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
|
||||||
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免
|
# 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段,
|
||||||
# Pydantic 再次拒绝 additionalProperties/patternProperties 接受的合法参数。
|
# 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。
|
||||||
return create_model(model_name, __config__=ConfigDict(extra="allow"), **fields)
|
return create_model(model_name, __config__=ConfigDict(extra="allow"))
|
||||||
|
|
||||||
|
|
||||||
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
||||||
|
|||||||
@@ -509,13 +509,38 @@ def test_mcp_argument_model_preserves_json_schema_additional_properties() -> Non
|
|||||||
"mcp-fixture.dynamic",
|
"mcp-fixture.dynamic",
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"properties": {"model_dump": {"type": "string"}},
|
||||||
|
"required": ["model_dump"],
|
||||||
"additionalProperties": {"type": "string"},
|
"additionalProperties": {"type": "string"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
arguments = arguments_model.model_validate({"dynamic_key": "value"})
|
arguments = arguments_model.model_validate(
|
||||||
|
{"model_dump": "method name remains data", "dynamic-key": "value"}
|
||||||
|
)
|
||||||
|
|
||||||
assert arguments.model_dump() == {"dynamic_key": "value"}
|
assert arguments.model_dump() == {
|
||||||
|
"model_dump": "method name remains data",
|
||||||
|
"dynamic-key": "value",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("APP_ENVIRONMENT", "production")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
container = build_container()
|
||||||
|
installed = container.plugins.install(MCP_FIXTURE)
|
||||||
|
assert installed.status == "permission_required"
|
||||||
|
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
|
||||||
|
try:
|
||||||
|
with pytest.raises(ExtensionError) as exc:
|
||||||
|
container.plugins.enable("mcp-fixture")
|
||||||
|
assert exc.value.code == "MCP_TRUST_APPROVAL_REQUIRED"
|
||||||
|
assert container.plugins.get_host_status("mcp-fixture").status == "stopped"
|
||||||
|
assert not container.tools.contains("mcp-fixture.echo")
|
||||||
|
finally:
|
||||||
|
container.plugins.shutdown()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None:
|
def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user