diff --git a/README.md b/README.md index ac7a7db..10d735b 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cd frontend pnpm test ``` -当前回归基线为后端 91 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 +当前回归基线为后端 92 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 构建产物位于 `frontend/dist`,该目录不提交到 Git。 diff --git a/backend/README.md b/backend/README.md index 6a0930e..3d4fde4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 uv run pytest ``` -当前基线为 91 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 +当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_` 注入;不要把真实密钥写入仓库。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 diff --git a/backend/app/container.py b/backend/app/container.py index 0cff70a..9263ca9 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent.builtin_tools import register_builtin_tools 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.providers import MockProvider, ProviderFactory, ProviderRegistry from app.providers.credentials import ( @@ -26,6 +26,7 @@ class ApplicationContainer: def build_container() -> ApplicationContainer: + settings = get_settings() credentials = EncryptedCredentialStore() provider_factory = ProviderFactory( ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) @@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer: tools = ToolRegistry() 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.enable("text-tools") diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index cacbb12..832b726 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -256,10 +256,13 @@ class PluginRuntime: tools: ToolRegistry, host: DeclarativePluginHost | None = None, mcp_bridge: McpBridge | None = None, + *, + allow_unsandboxed_mcp: bool = False, ) -> None: self.registry = tools self.host = host or DeclarativePluginHost() self.mcp = mcp_bridge or McpBridge() + self.allow_unsandboxed_mcp = allow_unsandboxed_mcp self._records: dict[str, _PluginRecord] = {} self._lock = threading.RLock() @@ -346,6 +349,16 @@ class PluginRuntime: status_code=409, 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) conflicts = [name for name in declared_tools if self.registry.contains(name)] if conflicts: @@ -665,27 +678,10 @@ def _arguments_model_from_schema( ) -> type[BaseModel]: if schema.get("type", "object") != "object": 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) - # 完整 JSON Schema 已在 ToolRegistry 中先行校验。这里允许额外字段,避免 - # Pydantic 再次拒绝 additionalProperties/patternProperties 接受的合法参数。 - return create_model(model_name, __config__=ConfigDict(extra="allow"), **fields) + # 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段, + # 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。 + return create_model(model_name, __config__=ConfigDict(extra="allow")) def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py index 5b368aa..29a2d92 100644 --- a/backend/tests/test_extension_core.py +++ b/backend/tests/test_extension_core.py @@ -509,13 +509,38 @@ def test_mcp_argument_model_preserves_json_schema_additional_properties() -> Non "mcp-fixture.dynamic", { "type": "object", + "properties": {"model_dump": {"type": "string"}}, + "required": ["model_dump"], "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: