From 2aca9cc06b95d7e0b4fc77098dbeacc2d724528e Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:16:09 +0800 Subject: [PATCH] fix(runtime): enforce canonical capability entrypoints (#6338) --- app/runtime/capabilities/runtime.py | 20 +++-- tests/test_capability_runtime.py | 134 +++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/app/runtime/capabilities/runtime.py b/app/runtime/capabilities/runtime.py index 59712ac0b..cec42e120 100644 --- a/app/runtime/capabilities/runtime.py +++ b/app/runtime/capabilities/runtime.py @@ -433,8 +433,16 @@ class CapabilityRuntime: module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1) module = sys.modules.get(module_name) namespace = getattr(module, "__dict__", None) if module is not None else None - if not isinstance(namespace, dict) or symbol_name not in namespace: - return implementation + if not isinstance(namespace, dict): + raise CapabilityAdapterContractError( + f"adapter 返回 {spec.id} 的 implementation 后," + f"canonical 模块 {module_name} 未加载" + ) + if symbol_name not in namespace: + raise CapabilityAdapterContractError( + f"adapter 返回 {spec.id} 的 implementation 后," + f"canonical 符号 {spec.entrypoint} 不存在" + ) canonical = namespace[symbol_name] if implementation is not canonical: raise CapabilityAdapterContractError( @@ -579,8 +587,8 @@ class CapabilityRuntime: state.pending_cleanup_error = pending_error raise if implementation is None: - implementation = self._sync_callback(adapter, "materialize", state.spec) - implementation = self._canonical_implementation(state.spec, implementation) + materialized = self._sync_callback(adapter, "materialize", state.spec) + implementation = self._canonical_implementation(state.spec, materialized) candidate = self._sync_callback( adapter, "create", @@ -766,8 +774,8 @@ class CapabilityRuntime: state.pending_cleanup_error = pending_error raise if implementation is None: - implementation = await self._async_callback(adapter, "materialize", state.spec) - implementation = self._canonical_implementation(state.spec, implementation) + materialized = await self._async_callback(adapter, "materialize", state.spec) + implementation = self._canonical_implementation(state.spec, materialized) candidate = await self._async_callback( adapter, "create", diff --git a/tests/test_capability_runtime.py b/tests/test_capability_runtime.py index 3d0641141..5958f15b7 100644 --- a/tests/test_capability_runtime.py +++ b/tests/test_capability_runtime.py @@ -4,10 +4,11 @@ import asyncio import sys import threading import types +from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path from typing import Any, Optional -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -62,6 +63,32 @@ class _Candidate: stopped: bool = False +class _SampleCapability: + pass + + +@pytest.fixture(autouse=True) +def isolate_sample_entrypoints() -> Iterator[None]: + """隔离合成 adapter 在 sys.modules 中公开的 canonical entrypoint。""" + module_names = ("sample_implementation", "other_implementation") + previous = {name: sys.modules[name] for name in module_names if name in sys.modules} + for name in module_names: + sys.modules.pop(name, None) + yield + for name in module_names: + sys.modules.pop(name, None) + if name in previous: + sys.modules[name] = previous[name] + + +def _materialize_sample(spec) -> type[_SampleCapability]: + module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1) + module = types.ModuleType(module_name) + setattr(module, symbol_name, _SampleCapability) + sys.modules[module_name] = module + return _SampleCapability + + class _SyncAdapter: execution_mode = AdapterExecutionMode.SYNC @@ -84,7 +111,7 @@ class _SyncAdapter: self.materialize_calls += 1 if self.fail_materialize: raise RuntimeError("materialize failed") - return object() + return _materialize_sample(spec) def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate: self.create_calls += 1 @@ -139,7 +166,7 @@ class _AsyncAdapter: await asyncio.sleep(0) if self.fail_materialize: raise RuntimeError("async materialize failed") - return object() + return _materialize_sample(spec) async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate: self.create_calls += 1 @@ -210,6 +237,107 @@ def test_materialize_failure_does_not_claim_resource_lifecycle_failure(tmp_path: assert snapshot.visible is False +def test_materialize_rejects_missing_canonical_module( + tmp_path: Path, +) -> None: + """adapter 未加载 manifest 指定模块时不得发布替代实现。""" + adapter = _SyncAdapter() + runtime = CapabilityRuntime( + _registry(tmp_path), + adapters={"sample": adapter}, + ) + + with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises( + CapabilityOperationError, + match="canonical 模块.*未加载", + ): + runtime.materialize("sample.capability", reason="invalid_adapter") + + snapshot = runtime.snapshot("sample.capability") + assert snapshot.materialization is CapabilityMaterializationState.FAILED + assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED + + +def test_materialize_rejects_missing_canonical_symbol( + tmp_path: Path, +) -> None: + """adapter 未公开 manifest 指定符号时不得发布替代实现。""" + sys.modules["sample_implementation"] = types.ModuleType("sample_implementation") + adapter = _SyncAdapter() + runtime = CapabilityRuntime( + _registry(tmp_path), + adapters={"sample": adapter}, + ) + + with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises( + CapabilityOperationError, + match="canonical 符号.*不存在", + ): + runtime.materialize("sample.capability", reason="invalid_adapter") + + snapshot = runtime.snapshot("sample.capability") + assert snapshot.materialization is CapabilityMaterializationState.FAILED + assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED + + +def test_activate_revalidates_missing_canonical_module_on_retry(tmp_path: Path) -> None: + """同步激活的物化合同失败不得被记为已解析并在重试时绕过。""" + adapter = _SyncAdapter() + runtime = CapabilityRuntime( + _registry(tmp_path), + adapters={"sample": adapter}, + ) + + with patch.object( + adapter, + "materialize", + return_value=_SampleCapability, + ) as materialize: + with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): + runtime.activate("sample.capability", reason="invalid_adapter") + with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): + runtime.activate( + "sample.capability", + reason="invalid_adapter_retry", + retry=True, + ) + + snapshot = runtime.snapshot("sample.capability") + assert snapshot.materialization is CapabilityMaterializationState.FAILED + assert snapshot.lifecycle is CapabilityLifecycleState.FAILED + assert adapter.create_calls == 0 + assert materialize.call_count == 2 + + +@pytest.mark.asyncio +async def test_activate_async_revalidates_missing_canonical_module_on_retry( + tmp_path: Path, +) -> None: + """异步激活的物化合同失败不得被记为已解析并在重试时绕过。""" + adapter = _AsyncAdapter() + runtime = CapabilityRuntime( + _registry(tmp_path), + adapters={"sample": adapter}, + ) + materialize = AsyncMock(return_value=_SampleCapability) + + with patch.object(adapter, "materialize", new=materialize): + with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): + await runtime.activate_async("sample.capability", reason="invalid_adapter") + with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): + await runtime.activate_async( + "sample.capability", + reason="invalid_adapter_retry", + retry=True, + ) + + snapshot = runtime.snapshot("sample.capability") + assert snapshot.materialization is CapabilityMaterializationState.FAILED + assert snapshot.lifecycle is CapabilityLifecycleState.FAILED + assert adapter.create_calls == 0 + assert materialize.await_count == 2 + + def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None: """显式旧导入存在时应复用 sys.modules 中的 canonical symbol。""" adapter = _SyncAdapter()