From 12b2e283c5ab58213aa04eafcc20d816fc54e427 Mon Sep 17 00:00:00 2001 From: 0YHR0 <97868579@qq.com> Date: Mon, 3 Aug 2026 15:48:09 +0800 Subject: [PATCH] Add runtime extension protocol --- .env.example | 2 + CHANGELOG.md | 3 + README.md | 7 + README.zh-CN.md | 5 + compose.yaml | 1 + docs/architecture/modules/README.md | 2 + .../architecture/modules/pack-sdk-boundary.md | 11 +- .../modules/runtime-extension-protocol.md | 94 +++++++ docs/implementation-status.md | 14 + src/agentmesh/api/app.py | 15 +- src/agentmesh/api/extension_routes.py | 70 +++++ src/agentmesh/bootstrap.py | 34 ++- src/agentmesh/config.py | 1 + src/agentmesh/extensions/__init__.py | 23 ++ src/agentmesh/extensions/builtin.py | 8 + src/agentmesh/extensions/runtime.py | 254 ++++++++++++++++++ src/agentmesh/extensions/sdk.py | 176 ++++++++++++ src/agentmesh/packs/catalog.py | 10 +- src/agentmesh/packs/music_studio/console.py | 12 +- src/agentmesh/packs/music_studio/extension.py | 116 ++++++++ src/agentmesh/packs/music_studio/routes.py | 9 +- tests/conftest.py | 31 ++- tests/test_runtime_extensions.py | 205 ++++++++++++++ 23 files changed, 1066 insertions(+), 37 deletions(-) create mode 100644 docs/architecture/modules/runtime-extension-protocol.md create mode 100644 src/agentmesh/api/extension_routes.py create mode 100644 src/agentmesh/extensions/__init__.py create mode 100644 src/agentmesh/extensions/builtin.py create mode 100644 src/agentmesh/extensions/runtime.py create mode 100644 src/agentmesh/extensions/sdk.py create mode 100644 src/agentmesh/packs/music_studio/extension.py create mode 100644 tests/test_runtime_extensions.py diff --git a/.env.example b/.env.example index 1068047..99349f7 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,8 @@ AGENTMESH_FEATURE_PROFILE=minimal # Market Intelligence Studio installer: # AGENTMESH_FEATURE_GATES=company_model=true,business_objects=true,company_packs=true AGENTMESH_FEATURE_GATES= +# Comma-separated trusted in-process extensions. Empty disables every installed extension. +AGENTMESH_RUNTIME_EXTENSIONS=agentmesh.music-studio # Required only when identity_rbac is explicitly enabled. Store SHA-256 digests, never raw tokens. AGENTMESH_IDENTITY_PRINCIPALS_JSON=[] # Explicit opt-in persistent identity can additionally verify registered OIDC subjects. diff --git a/CHANGELOG.md b/CHANGELOG.md index f904960..fb24843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ release tags and PEP 440 for the Python package. discoverable scenario package while retaining its existing API and compatibility import. - Scenario-owned Music Studio runtime, provider adapters, HTTP routes, and workspace assets with unchanged public URLs and compatibility re-exports for pre-alpha Python imports. +- Trusted in-process Runtime Extension API v0.1 with entry-point discovery, manifest and collision + validation, capability-limited service factories, explicit enablement, lifecycle health, and + fail-closed Music Studio integration. ## 0.1.0-alpha.1 — 2026-07-27 diff --git a/README.md b/README.md index b65e7b3..7045027 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,13 @@ manifest, and SHA-256-linked release manifest. It uses no model or music API key external request. The focused workspace defaults to English and can switch to Chinese; low-level Tasks, Runs, Artifacts, and governance records remain in the Admin Console. +Music Studio is loaded through the trusted in-process Runtime Extension API rather than imported +by the core application. `AGENTMESH_RUNTIME_EXTENSIONS=agentmesh.music-studio` is the default; +set it to an empty value to disable every installed extension. Inspect effective versions, +required Features/Credentials, permissions, workspaces, and health at `GET /api/v1/extensions`. +Third-party trusted Python packages can publish the `agentmesh.runtime_extensions` entry-point +group. See the [Runtime Extension Protocol](docs/architecture/modules/runtime-extension-protocol.md). + The same gates expose the built-in **Market Intelligence Studio** in the Admin Console's **Company** tab. Previewing it shows all 32 resource mutations, permissions, credentials, and the external-write boundary. One click creates the Company, eight departments, 17 Positions, seven diff --git a/README.zh-CN.md b/README.zh-CN.md index eae4988..b7d60b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,11 @@ AGENTMESH_FEATURE_PROFILE=full AGENTMESH_FEATURE_GATES=reviewed_execution=true,coordinated_execution=true,agent_registry_management=true,artifact_service=true,mcp_read_tools=true,observability=true,budget_admission=true ``` +Music Studio 现在通过受信任的进程内 Runtime Extension API 加载,而不是由核心应用直接导入。 +默认配置为 `AGENTMESH_RUNTIME_EXTENSIONS=agentmesh.music-studio`;将其设为空可禁用全部已安装 +扩展。`GET /api/v1/extensions` 可以查看版本、健康状态、所需 Feature/API Key、权限和工作区。 +扩展开发说明见 [Runtime Extension Protocol](docs/architecture/modules/runtime-extension-protocol.md)。 + Identity/RBAC 在所有内置 Profile 中都保持关闭,必须由部署者显式配置凭据后开启。 第一个 Virtual Company 模块需要显式开启: diff --git a/compose.yaml b/compose.yaml index 6ae38ca..6a438ef 100644 --- a/compose.yaml +++ b/compose.yaml @@ -30,6 +30,7 @@ x-agentmesh-environment: &agentmesh-environment # Keep the minimal profile, but make the first-run Console demonstrate a real # multi-Agent DAG. Operators can still override this with an explicit value. AGENTMESH_FEATURE_GATES: ${AGENTMESH_FEATURE_GATES:-coordinated_execution=true} + AGENTMESH_RUNTIME_EXTENSIONS: ${AGENTMESH_RUNTIME_EXTENSIONS:-agentmesh.music-studio} AGENTMESH_IDENTITY_PRINCIPALS_JSON: ${AGENTMESH_IDENTITY_PRINCIPALS_JSON:-[]} AGENTMESH_IDENTITY_OIDC_ISSUER: ${AGENTMESH_IDENTITY_OIDC_ISSUER:-} AGENTMESH_IDENTITY_OIDC_AUDIENCE: ${AGENTMESH_IDENTITY_OIDC_AUDIENCE:-} diff --git a/docs/architecture/modules/README.md b/docs/architecture/modules/README.md index 9b24a40..f6437f5 100644 --- a/docs/architecture/modules/README.md +++ b/docs/architecture/modules/README.md @@ -68,6 +68,8 @@ The complete target design is maintained separately: Formal module documents: +- [Runtime Extension Protocol](runtime-extension-protocol.md) + - [Task and execution domain](formal/task-and-execution-domain.md) - [Persistence and consistency](formal/persistence-and-consistency.md) - [Orchestrator and scheduler](formal/orchestrator-and-scheduler.md) diff --git a/docs/architecture/modules/pack-sdk-boundary.md b/docs/architecture/modules/pack-sdk-boundary.md index 0de9c51..92730cf 100644 --- a/docs/architecture/modules/pack-sdk-boundary.md +++ b/docs/architecture/modules/pack-sdk-boundary.md @@ -52,8 +52,9 @@ that exposes the same definition contract after a separate trust and loading des ## Remaining separation work Phases 1 and 2 separate both the declarative contract and the complete Music Studio implementation -inside the Python package. A later phase can publish that directory as a separately versioned -distribution once the runtime extension protocol and signed external Pack loading/trust model are -stable. The older Market Intelligence template and Operations helpers must also move onto the same -definition contract before the whole application layer is scenario-neutral. Until then, physical -repository separation would create release coupling without a safe installation boundary. +inside the Python package. Runtime Extension API v0.1 now discovers its executable surface through +a generic trusted in-process contract, so the core application and bootstrap no longer import +Music Studio code. A later phase can publish the scenario as a separately versioned distribution +after the signed installation/trust model is stable. The older Market Intelligence template and +Operations helpers must also move onto the same definition contract before the whole application +layer is scenario-neutral. diff --git a/docs/architecture/modules/runtime-extension-protocol.md b/docs/architecture/modules/runtime-extension-protocol.md new file mode 100644 index 0000000..a727aa0 --- /dev/null +++ b/docs/architecture/modules/runtime-extension-protocol.md @@ -0,0 +1,94 @@ +# Runtime Extension Protocol + +Status: Implemented v0.1 (trusted in-process) + +AgentMesh Runtime Extension API v0.1 is the executable boundary between the operating core and a +business scenario. The core owns durable Tasks, Agents, governance, persistence, credentials, +memory, Artifacts, and observability. An extension owns its workflow service, provider adapters, +HTTP API, focused workspace, health probe, and Company Template definitions. + +The contract lives in `agentmesh.extensions.sdk`. The current API version is `0.1`. + +## Contract + +A `RuntimeExtensionDefinition` provides: + +- an immutable `ExtensionManifest`; +- a service factory receiving an `ExtensionContext`; +- an API registrar for routes and workspace assets; +- zero or more `CompanyTemplateDefinition` values; +- a health probe and idempotent stop callback. + +The manifest declares the extension identifier and semantic version, Runtime API version, required +core services, provided namespaced services, Features, Credentials, permissions, workspaces, and +whether external writes are enabled. AgentMesh validates identifiers, versions, Feature names, +service surfaces, duplicate extension IDs, and extension/core route or workspace/asset collisions +before serving work. + +The service factory cannot receive the `ApplicationContainer` or database engine. It receives a +read-only mapping of explicitly exposed core capabilities through stable `CoreServiceKey` names. +This is an API boundary, not a process sandbox. + +## Discovery and enablement + +Built-in and installed extensions share `RuntimeExtensionRegistry`. A separately distributed +Python package publishes this entry point: + +```toml +[project.entry-points."agentmesh.runtime_extensions"] +my_scenario = "my_scenario.extension:EXTENSION" +``` + +Only packages explicitly installed by the operator are discoverable. Installed entry points are +trusted Python code and are imported during discovery, so operators must not install untrusted +extension packages. Runtime enablement controls service creation and use; it is not a defense +against malicious package import-time code. + +Enabled extensions are configured as a comma-separated allowlist: + +```dotenv +AGENTMESH_RUNTIME_EXTENSIONS=agentmesh.music-studio +``` + +An empty value disables all installed extensions. `*` enables all discovered extensions and cannot +be combined with explicit identifiers. Unknown identifiers fail startup instead of being ignored. + +## Lifecycle + +```text +discover -> validate -> load services -> probe -> serve -> stop +``` + +API routes are registered from validated definitions while constructing the FastAPI application. +An enabled extension then creates exactly its declared service keys. Disabled extensions remain +visible to operators but service and workspace access fails closed with HTTP 503. Missing optional +Feature activation produces a `degraded` status; route-level Feature gates still enforce the +operation. `ApplicationContainer.close()` invokes every loaded extension stop callback once. + +Operators can inspect the effective state at `GET /api/v1/extensions`. The response discloses +version, health, missing Features, required Credentials and permissions, service keys, workspace +routes, and the external-write boundary. + +## Music Studio proof + +Music Studio is the first implementation. The core API and bootstrap modules do not import its +runtime, routes, console, or provider code. The built-in registry discovers one definition, the +generic runtime supplies its required services, and its registrar preserves these URLs: + +- `/music-studio`; +- `/console/assets/music-studio.*`; +- `/api/v1/music-studio/*`. + +Its Company Template is also projected into `PackCatalog`, so declarative installation and +executable runtime discovery come from one scenario definition. + +## Deliberate v0.1 limits + +- extensions run in the AgentMesh API process and have the privileges of that process; +- installing, removing, or changing the allowlist requires a restart; +- extension-owned database migrations are not accepted; +- signed bundles, registry trust policy, dependency resolution, hot reload, process isolation, and + remote A2A extensions remain later protocol versions. + +The next security step should be a signed installation/preflight model. Process isolation should +only follow after the in-process contract is proven by a second independently maintained scenario. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index e57c530..90a3d8b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -193,6 +193,20 @@ Company Pack SDK separation phase 2 on 2026-08-03 additionally: - left external executable Pack loading deliberately unsupported until a signed trust and runtime extension protocol is specified. +Runtime Extension API v0.1 on 2026-08-03 additionally: + +- passed 426 non-PostgreSQL tests at 83.25% line coverage (gate: 80%); +- introduced a trusted in-process extension manifest, registry, entry-point discovery, controlled + core-service context, exact service-surface validation, health probe, and stop lifecycle; +- added explicit `AGENTMESH_RUNTIME_EXTENSIONS` allowlisting, fail-fast unknown identifiers, and + fail-closed disabled service/workspace access; +- exposed `GET /api/v1/extensions` for version, health, Feature, Credential, permission, workspace, + service, and external-write disclosure; +- migrated Music Studio to the generic runtime so the core API/bootstrap no longer imports its + routes, console, runtime service, or providers; +- kept installed Python extensions explicitly trusted and deferred signatures, sandboxing, hot + reload, extension migrations, and remote execution to later protocol revisions. + Market Intelligence Studio baseline verification on 2026-07-30 additionally: - passed 390 non-PostgreSQL tests at 82.71% line coverage (gate: 80%); diff --git a/src/agentmesh/api/app.py b/src/agentmesh/api/app.py index 1a21e26..e124555 100644 --- a/src/agentmesh/api/app.py +++ b/src/agentmesh/api/app.py @@ -19,6 +19,7 @@ from agentmesh.api.console import register_console from agentmesh.api.credential_routes import router as credential_router from agentmesh.api.event_routes import router as event_router +from agentmesh.api.extension_routes import router as extension_router from agentmesh.api.feature_routes import router as feature_router from agentmesh.api.financial_governance_routes import ( router as financial_governance_router, @@ -110,8 +111,8 @@ TaskNotFound, ToolInvocationFailed, ) -from agentmesh.packs.music_studio.console import register_music_studio_console -from agentmesh.packs.music_studio.routes import router as music_studio_router +from agentmesh.extensions.builtin import RUNTIME_EXTENSION_REGISTRY +from agentmesh.extensions.sdk import RuntimeExtensionUnavailable def create_app(container: ApplicationContainer | None = None) -> FastAPI: @@ -139,6 +140,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: application.include_router(a2a_router) application.include_router(credential_router) application.include_router(event_router) + application.include_router(extension_router) application.include_router(activity_router) application.include_router(artifact_router) application.include_router(business_object_router) @@ -151,18 +153,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: application.include_router(company_template_router) application.include_router(mcp_router) application.include_router(mcp_registry_router) - application.include_router(music_studio_router) application.include_router(policy_router) application.include_router(quota_router) application.include_router(office_router) - # Scenario-owned asset routes must be registered before the core static mount. - register_music_studio_console(application) + # Extension asset routes must be registered before the core static mount. + RUNTIME_EXTENSION_REGISTRY.register_api(application) register_console(application) _register_error_handlers(application) return application def _register_error_handlers(application: FastAPI) -> None: + application.add_exception_handler( + RuntimeExtensionUnavailable, + lambda request, exc: _error(503, "runtime_extension_unavailable", str(exc)), + ) for error_type in (AuthenticationRequired, AuthenticationFailed): application.add_exception_handler( error_type, diff --git a/src/agentmesh/api/extension_routes.py b/src/agentmesh/api/extension_routes.py new file mode 100644 index 0000000..acea221 --- /dev/null +++ b/src/agentmesh/api/extension_routes.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from agentmesh.extensions.runtime import RuntimeExtensionStatus + +router = APIRouter(prefix="/api/v1/extensions", tags=["runtime-extensions"]) + + +class ExtensionWorkspaceResponse(BaseModel): + route: str + name: str + asset_prefix: str | None + + +class RuntimeExtensionResponse(BaseModel): + identifier: str + name: str + version: str + api_version: str + description: str + enabled: bool + health: str + message: str + required_features: list[str] + missing_features: list[str] + required_credentials: list[str] + permissions: list[str] + provided_services: list[str] + loaded_services: list[str] + workspaces: list[ExtensionWorkspaceResponse] + external_writes_enabled: bool + + @classmethod + def from_status(cls, status: RuntimeExtensionStatus) -> RuntimeExtensionResponse: + manifest = status.manifest + return cls( + identifier=manifest.identifier, + name=manifest.name, + version=manifest.version, + api_version=manifest.api_version, + description=manifest.description, + enabled=status.enabled, + health=status.health, + message=status.message, + required_features=list(manifest.required_features), + missing_features=list(status.missing_features), + required_credentials=list(manifest.required_credentials), + permissions=list(manifest.permissions), + provided_services=list(manifest.provided_services), + loaded_services=list(status.service_keys), + workspaces=[ + ExtensionWorkspaceResponse( + route=item.route, + name=item.name, + asset_prefix=item.asset_prefix, + ) + for item in manifest.workspaces + ], + external_writes_enabled=manifest.external_writes_enabled, + ) + + +@router.get("", response_model=list[RuntimeExtensionResponse]) +def list_runtime_extensions(request: Request) -> list[RuntimeExtensionResponse]: + return [ + RuntimeExtensionResponse.from_status(item) + for item in request.app.state.container.extension_runtime.statuses() + ] diff --git a/src/agentmesh/bootstrap.py b/src/agentmesh/bootstrap.py index f84e7ea..cc0024e 100644 --- a/src/agentmesh/bootstrap.py +++ b/src/agentmesh/bootstrap.py @@ -51,6 +51,9 @@ from agentmesh.domain.model_runtime import ModelRuntimePolicy from agentmesh.domain.pricing import UsagePriceCatalog from agentmesh.domain.tools import WORKSPACE_READ_TOOL_KEY, ToolBinding, ToolSideEffect +from agentmesh.extensions.builtin import RUNTIME_EXTENSION_REGISTRY +from agentmesh.extensions.runtime import ExtensionRuntime +from agentmesh.extensions.sdk import CoreServiceKey, ExtensionContext from agentmesh.features import Feature, FeatureGateSet from agentmesh.infrastructure.artifact_storage import LocalArtifactBlobStore from agentmesh.infrastructure.postgres.office_repositories import ( @@ -95,7 +98,6 @@ VersionBoundAgentExecutor, ) from agentmesh.orchestration.workflow import LangGraphWorkflowRunner -from agentmesh.packs.music_studio.runtime import MusicStudioService from agentmesh.workers.a2a_reconciliation import A2AReconciliationWorker from agentmesh.workers.execution import RedisRunWorker @@ -132,12 +134,13 @@ class ApplicationContainer: company_pack_service: CompanyPackService market_research_service: MarketResearchService research_materialization_service: ResearchMaterializationService - music_studio_service: MusicStudioService + extension_runtime: ExtensionRuntime mcp_catalog_client: OfficialMcpRegistryClient | None = None event_stream: RedisDomainEventStream | None = None close_callback: Callable[[], None] = lambda: None def close(self) -> None: + self.extension_runtime.close() self.close_callback() @@ -404,13 +407,24 @@ def build_api_container(settings: Settings | None = None) -> ApplicationContaine artifact_service=artifact_service, tenant_id=runtime_settings.tenant_id, ) - music_studio_service = MusicStudioService( - uow_factory=uow_factory, - task_service=task_service, - registry_service=registry_service, - business_object_service=business_object_service, - artifact_service=artifact_service, - tenant_id=runtime_settings.tenant_id, + extension_runtime = ExtensionRuntime.load( + RUNTIME_EXTENSION_REGISTRY, + ExtensionContext( + tenant_id=runtime_settings.tenant_id, + services={ + CoreServiceKey.UNIT_OF_WORK_FACTORY.value: uow_factory, + CoreServiceKey.TASKS.value: task_service, + CoreServiceKey.AGENT_REGISTRY.value: registry_service, + CoreServiceKey.ARTIFACTS.value: artifact_service, + CoreServiceKey.BUSINESS_OBJECTS.value: business_object_service, + CoreServiceKey.COMPANY_PACKS.value: company_pack_service, + CoreServiceKey.CREDENTIALS.value: credential_broker_service, + CoreServiceKey.MEMORY.value: organizational_memory_service, + CoreServiceKey.POLICIES.value: policy_service, + }, + ), + feature_gates, + runtime_settings.runtime_extensions, ) def close() -> None: @@ -449,7 +463,7 @@ def close() -> None: company_pack_service=company_pack_service, market_research_service=market_research_service, research_materialization_service=research_materialization_service, - music_studio_service=music_studio_service, + extension_runtime=extension_runtime, mcp_catalog_client=OfficialMcpRegistryClient(), event_stream=event_stream, close_callback=close, diff --git a/src/agentmesh/config.py b/src/agentmesh/config.py index bc5ad2e..f8d5704 100644 --- a/src/agentmesh/config.py +++ b/src/agentmesh/config.py @@ -88,6 +88,7 @@ class Settings(BaseSettings): langfuse_timeout_seconds: int = Field(default=5, ge=1, le=60) feature_profile: str = "minimal" feature_gates: str = "" + runtime_extensions: str = "agentmesh.music-studio" operations_batch_size: int = Field(default=50, ge=1, le=500) operations_scan_seconds: int = Field(default=5, ge=1, le=300) identity_principals_json: str = "[]" diff --git a/src/agentmesh/extensions/__init__.py b/src/agentmesh/extensions/__init__.py new file mode 100644 index 0000000..29752e1 --- /dev/null +++ b/src/agentmesh/extensions/__init__.py @@ -0,0 +1,23 @@ +"""Trusted in-process runtime extension protocol.""" + +from agentmesh.extensions.sdk import ( + RUNTIME_EXTENSION_API_VERSION, + CoreServiceKey, + ExtensionContext, + ExtensionHealth, + ExtensionManifest, + ExtensionServices, + ExtensionWorkspace, + RuntimeExtensionDefinition, +) + +__all__ = [ + "RUNTIME_EXTENSION_API_VERSION", + "CoreServiceKey", + "ExtensionContext", + "ExtensionHealth", + "ExtensionManifest", + "ExtensionServices", + "ExtensionWorkspace", + "RuntimeExtensionDefinition", +] diff --git a/src/agentmesh/extensions/builtin.py b/src/agentmesh/extensions/builtin.py new file mode 100644 index 0000000..0611137 --- /dev/null +++ b/src/agentmesh/extensions/builtin.py @@ -0,0 +1,8 @@ +"""Composition catalog for built-in and installed trusted runtime extensions.""" + +from agentmesh.extensions.runtime import RuntimeExtensionRegistry +from agentmesh.packs.music_studio.extension import EXTENSION as MUSIC_STUDIO_EXTENSION + +RUNTIME_EXTENSION_REGISTRY = RuntimeExtensionRegistry.discover((MUSIC_STUDIO_EXTENSION,)) + +__all__ = ["MUSIC_STUDIO_EXTENSION", "RUNTIME_EXTENSION_REGISTRY"] diff --git a/src/agentmesh/extensions/runtime.py b/src/agentmesh/extensions/runtime.py new file mode 100644 index 0000000..d36f309 --- /dev/null +++ b/src/agentmesh/extensions/runtime.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from dataclasses import dataclass +from importlib.metadata import entry_points + +from fastapi import FastAPI + +from agentmesh.extensions.sdk import ( + ExtensionContext, + ExtensionHealth, + ExtensionManifest, + ExtensionServices, + InvalidRuntimeExtension, + RuntimeExtensionDefinition, + RuntimeExtensionUnavailable, +) +from agentmesh.features import Feature, FeatureGateSet + +ENTRY_POINT_GROUP = "agentmesh.runtime_extensions" + + +@dataclass(frozen=True) +class RuntimeExtensionStatus: + manifest: ExtensionManifest + enabled: bool + health: str + message: str + missing_features: tuple[str, ...] + service_keys: tuple[str, ...] + + +class RuntimeExtensionRegistry: + """Discovers and validates explicitly installed trusted extensions.""" + + def __init__(self, definitions: tuple[RuntimeExtensionDefinition, ...] = ()) -> None: + self._definitions: dict[str, RuntimeExtensionDefinition] = {} + self._workspace_routes: set[str] = set() + self._asset_prefixes: set[str] = set() + for definition in definitions: + self.register(definition) + + @classmethod + def discover( + cls, + builtins: tuple[RuntimeExtensionDefinition, ...] = (), + ) -> RuntimeExtensionRegistry: + registry = cls(builtins) + for point in entry_points(group=ENTRY_POINT_GROUP): + candidate = point.load() + if not isinstance(candidate, RuntimeExtensionDefinition): + raise InvalidRuntimeExtension( + f"Entry point '{point.name}' did not expose RuntimeExtensionDefinition" + ) + registry.register(candidate) + return registry + + def register(self, definition: RuntimeExtensionDefinition) -> None: + identifier = definition.manifest.identifier + if identifier in self._definitions: + raise InvalidRuntimeExtension(f"Extension '{identifier}' is already registered") + for workspace in definition.manifest.workspaces: + if workspace.route in self._workspace_routes: + raise InvalidRuntimeExtension( + f"Extension workspace route '{workspace.route}' is already registered" + ) + if workspace.asset_prefix and workspace.asset_prefix in self._asset_prefixes: + raise InvalidRuntimeExtension( + f"Extension asset prefix '{workspace.asset_prefix}' is already registered" + ) + self._definitions[identifier] = definition + self._workspace_routes.update(item.route for item in definition.manifest.workspaces) + self._asset_prefixes.update( + item.asset_prefix for item in definition.manifest.workspaces if item.asset_prefix + ) + + def list(self) -> tuple[RuntimeExtensionDefinition, ...]: + return tuple(self._definitions[key] for key in sorted(self._definitions)) + + def get(self, identifier: str) -> RuntimeExtensionDefinition: + try: + return self._definitions[identifier] + except KeyError as exc: + raise InvalidRuntimeExtension(f"Unknown runtime extension '{identifier}'") from exc + + def register_api(self, application: FastAPI) -> None: + seen = {key for route in application.routes if (key := _route_key(route)) is not None} + for definition in self.list(): + before = len(application.routes) + definition.api_registrar(application) + added = application.routes[before:] + added_paths: set[str] = set() + for route in added: + key = _route_key(route) + if key is None: + continue + if key in seen: + raise InvalidRuntimeExtension( + f"Extension '{definition.manifest.identifier}' registered conflicting " + f"route '{key[0]}'" + ) + seen.add(key) + added_paths.add(key[0]) + for workspace in definition.manifest.workspaces: + if workspace.route not in added_paths: + raise InvalidRuntimeExtension( + f"Extension '{definition.manifest.identifier}' did not register declared " + f"workspace '{workspace.route}'" + ) + if workspace.asset_prefix and not any( + path.startswith(workspace.asset_prefix) for path in added_paths + ): + raise InvalidRuntimeExtension( + f"Extension '{definition.manifest.identifier}' did not register declared " + f"asset prefix '{workspace.asset_prefix}'" + ) + + +class ExtensionRuntime: + """Loaded service instances and lifecycle state for registered extensions.""" + + def __init__( + self, + *, + registry: RuntimeExtensionRegistry, + loaded: dict[str, ExtensionServices], + enabled: frozenset[str], + missing_features: dict[str, tuple[str, ...]], + ) -> None: + self._registry = registry + self._loaded = loaded + self._enabled = enabled + self._missing_features = missing_features + self._closed = False + + @classmethod + def load( + cls, + registry: RuntimeExtensionRegistry, + context: ExtensionContext, + feature_gates: FeatureGateSet, + enabled: str, + ) -> ExtensionRuntime: + available = {item.manifest.identifier for item in registry.list()} + enabled_ids = _parse_enabled(enabled, available) + loaded: dict[str, ExtensionServices] = {} + missing_features: dict[str, tuple[str, ...]] = {} + for identifier in sorted(enabled_ids): + definition = registry.get(identifier) + missing_services = tuple( + key for key in definition.manifest.required_core_services if not context.has(key) + ) + if missing_services: + names = ", ".join(missing_services) + raise InvalidRuntimeExtension( + f"Extension '{identifier}' requires unavailable core service(s): {names}" + ) + values = dict(definition.service_factory(context)) + declared = set(definition.manifest.provided_services) + if set(values) != declared: + raise InvalidRuntimeExtension( + f"Extension '{identifier}' service factory must provide exactly: " + + ", ".join(sorted(declared)) + ) + loaded[identifier] = ExtensionServices(identifier, values) + missing_features[identifier] = tuple( + feature + for feature in definition.manifest.required_features + if not feature_gates.is_enabled(Feature(feature)) + ) + return cls( + registry=registry, + loaded=loaded, + enabled=frozenset(enabled_ids), + missing_features=missing_features, + ) + + def require_loaded(self, identifier: str) -> ExtensionServices: + value = self._loaded.get(identifier) + if value is None: + raise RuntimeExtensionUnavailable(f"Runtime extension '{identifier}' is disabled") + return value + + def require_service(self, identifier: str, service_key: str) -> object: + return self.require_loaded(identifier).require(service_key) + + def statuses(self) -> tuple[RuntimeExtensionStatus, ...]: + values: list[RuntimeExtensionStatus] = [] + for definition in self._registry.list(): + identifier = definition.manifest.identifier + services = self._loaded.get(identifier) + if services is None: + values.append( + RuntimeExtensionStatus( + manifest=definition.manifest, + enabled=False, + health="disabled", + message="Extension is installed but disabled", + missing_features=(), + service_keys=(), + ) + ) + continue + missing = self._missing_features.get(identifier, ()) + try: + probe = definition.health_probe(services) + except Exception as exc: # pragma: no cover - defensive boundary + probe = ExtensionHealth(status="unhealthy", message=str(exc)) + health = probe.status + message = probe.message + if missing and health == "ready": + health = "degraded" + message = "Required Features are disabled: " + ", ".join(missing) + values.append( + RuntimeExtensionStatus( + manifest=definition.manifest, + enabled=True, + health=health, + message=message, + missing_features=missing, + service_keys=services.keys(), + ) + ) + return tuple(values) + + def close(self) -> None: + if self._closed: + return + for definition in reversed(self._registry.list()): + services = self._loaded.get(definition.manifest.identifier) + if services is not None: + definition.stop_callback(services) + self._closed = True + + +def _parse_enabled(value: str, available: set[str]) -> set[str]: + requested = {item.strip() for item in value.split(",") if item.strip()} + if requested == {"*"}: + return set(available) + if "*" in requested: + raise InvalidRuntimeExtension("'*' cannot be combined with explicit extension identifiers") + unknown = requested - available + if unknown: + raise InvalidRuntimeExtension( + "Unknown enabled runtime extension(s): " + ", ".join(sorted(unknown)) + ) + return requested + + +def _route_key(route: object) -> tuple[str, frozenset[str]] | None: + path = getattr(route, "path", None) + if not isinstance(path, str): + return None + methods = frozenset(getattr(route, "methods", None) or ()) + return path, methods diff --git a/src/agentmesh/extensions/sdk.py b/src/agentmesh/extensions/sdk.py new file mode 100644 index 0000000..551e38c --- /dev/null +++ b/src/agentmesh/extensions/sdk.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import TYPE_CHECKING, Literal + +from agentmesh.features import Feature +from agentmesh.packs.sdk import CompanyTemplateDefinition + +if TYPE_CHECKING: + from fastapi import FastAPI + +RUNTIME_EXTENSION_API_VERSION = "0.1" +_IDENTIFIER = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)+$") +_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +class InvalidRuntimeExtension(ValueError): + """Raised when a trusted extension violates the runtime contract.""" + + +class RuntimeExtensionUnavailable(RuntimeError): + """Raised when a disabled or failed extension service is requested.""" + + +class CoreServiceKey(str, Enum): + """Stable names for core capabilities exposed to trusted extensions.""" + + UNIT_OF_WORK_FACTORY = "unit_of_work_factory" + TASKS = "tasks" + AGENT_REGISTRY = "agent_registry" + ARTIFACTS = "artifacts" + BUSINESS_OBJECTS = "business_objects" + COMPANY_PACKS = "company_packs" + CREDENTIALS = "credentials" + MEMORY = "memory" + POLICIES = "policies" + + +@dataclass(frozen=True) +class ExtensionWorkspace: + route: str + name: str + asset_prefix: str | None = None + + def __post_init__(self) -> None: + if not self.route.startswith("/") or self.route == "/": + raise InvalidRuntimeExtension( + "Extension workspace route must be an absolute non-root path" + ) + if not self.name.strip(): + raise InvalidRuntimeExtension("Extension workspace name is required") + if self.asset_prefix is not None and not self.asset_prefix.startswith("/"): + raise InvalidRuntimeExtension("Extension asset prefix must be an absolute path") + + +@dataclass(frozen=True) +class ExtensionManifest: + identifier: str + name: str + version: str + description: str + required_core_services: tuple[str, ...] + provided_services: tuple[str, ...] + required_features: tuple[str, ...] = () + required_credentials: tuple[str, ...] = () + permissions: tuple[str, ...] = () + workspaces: tuple[ExtensionWorkspace, ...] = () + external_writes_enabled: bool = False + api_version: str = RUNTIME_EXTENSION_API_VERSION + + def __post_init__(self) -> None: + if not _IDENTIFIER.fullmatch(self.identifier): + raise InvalidRuntimeExtension(f"Invalid extension identifier '{self.identifier}'") + if not self.name.strip() or not self.description.strip(): + raise InvalidRuntimeExtension("Extension name and description are required") + if not _VERSION.fullmatch(self.version): + raise InvalidRuntimeExtension(f"Invalid extension version '{self.version}'") + if self.api_version != RUNTIME_EXTENSION_API_VERSION: + raise InvalidRuntimeExtension( + f"Unsupported runtime extension API version '{self.api_version}'" + ) + self._require_unique("required core service", self.required_core_services) + self._require_unique("provided service", self.provided_services) + self._require_unique("required Feature", self.required_features) + self._require_unique("required Credential", self.required_credentials) + self._require_unique("permission", self.permissions) + if not self.provided_services: + raise InvalidRuntimeExtension("Extension must declare at least one provided service") + for feature in self.required_features: + try: + Feature(feature) + except ValueError as exc: + raise InvalidRuntimeExtension( + f"Extension requires unknown Feature '{feature}'" + ) from exc + + @staticmethod + def _require_unique(label: str, values: tuple[str, ...]) -> None: + normalized = [value.strip() for value in values] + if any(not value for value in normalized): + raise InvalidRuntimeExtension(f"Extension {label} names cannot be blank") + if len(set(normalized)) != len(normalized): + raise InvalidRuntimeExtension(f"Extension {label} names must be unique") + + +@dataclass(frozen=True) +class ExtensionHealth: + status: Literal["ready", "degraded", "unhealthy"] + message: str = "" + + +class ExtensionContext: + """Capability-limited context supplied by the AgentMesh composition root.""" + + def __init__(self, *, tenant_id: str, services: Mapping[str, object]) -> None: + self.tenant_id = tenant_id + self._services = MappingProxyType(dict(services)) + + def require(self, key: str | CoreServiceKey) -> object: + value = self._services.get(str(key.value if isinstance(key, CoreServiceKey) else key)) + if value is None: + raise InvalidRuntimeExtension(f"Required core service '{key}' is unavailable") + return value + + def has(self, key: str | CoreServiceKey) -> bool: + normalized = key.value if isinstance(key, CoreServiceKey) else key + return normalized in self._services + + +class ExtensionServices: + """Immutable, extension-scoped service collection.""" + + def __init__(self, identifier: str, services: Mapping[str, object]) -> None: + self.identifier = identifier + self._services = MappingProxyType(dict(services)) + + def require(self, key: str) -> object: + value = self._services.get(key) + if value is None: + raise RuntimeExtensionUnavailable( + f"Extension '{self.identifier}' does not provide service '{key}'" + ) + return value + + def keys(self) -> tuple[str, ...]: + return tuple(sorted(self._services)) + + +ServiceFactory = Callable[[ExtensionContext], Mapping[str, object]] +ApiRegistrar = Callable[["FastAPI"], None] +HealthProbe = Callable[[ExtensionServices], ExtensionHealth] +StopCallback = Callable[[ExtensionServices], None] + + +def _ready(_: ExtensionServices) -> ExtensionHealth: + return ExtensionHealth(status="ready") + + +def _stop(_: ExtensionServices) -> None: + return None + + +@dataclass(frozen=True) +class RuntimeExtensionDefinition: + """Executable contract for an explicitly trusted in-process extension.""" + + manifest: ExtensionManifest + service_factory: ServiceFactory + api_registrar: ApiRegistrar + company_templates: tuple[CompanyTemplateDefinition, ...] = () + health_probe: HealthProbe = _ready + stop_callback: StopCallback = _stop diff --git a/src/agentmesh/packs/catalog.py b/src/agentmesh/packs/catalog.py index a2deaf5..28d2dcf 100644 --- a/src/agentmesh/packs/catalog.py +++ b/src/agentmesh/packs/catalog.py @@ -3,9 +3,15 @@ Third-party repositories can construct their own ``PackCatalog`` with the same SDK. """ -from agentmesh.packs.music_studio import DEFINITION as MUSIC_STUDIO +from agentmesh.extensions.builtin import RUNTIME_EXTENSION_REGISTRY from agentmesh.packs.sdk import PackCatalog -BUILTIN_PACK_CATALOG = PackCatalog([MUSIC_STUDIO]) +_DEFINITIONS = tuple( + template + for extension in RUNTIME_EXTENSION_REGISTRY.list() + for template in extension.company_templates +) +BUILTIN_PACK_CATALOG = PackCatalog(_DEFINITIONS) +MUSIC_STUDIO = BUILTIN_PACK_CATALOG.get("music-studio") __all__ = ["BUILTIN_PACK_CATALOG", "MUSIC_STUDIO"] diff --git a/src/agentmesh/packs/music_studio/console.py b/src/agentmesh/packs/music_studio/console.py index 539232a..8ae71ba 100644 --- a/src/agentmesh/packs/music_studio/console.py +++ b/src/agentmesh/packs/music_studio/console.py @@ -2,10 +2,11 @@ from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.responses import FileResponse from agentmesh.api.console import console_headers +from agentmesh.packs.music_studio.extension import EXTENSION_ID ASSET_DIRECTORY = Path(__file__).with_name("assets") @@ -14,11 +15,13 @@ def register_music_studio_console(application: FastAPI) -> None: """Register scenario-owned UI routes while preserving public URLs.""" @application.get("/music-studio", include_in_schema=False) - def music_studio_index() -> FileResponse: + def music_studio_index(request: Request) -> FileResponse: + request.app.state.container.extension_runtime.require_loaded(EXTENSION_ID) return FileResponse(ASSET_DIRECTORY / "music-studio.html", headers=console_headers()) @application.get("/console/assets/music-studio.css", include_in_schema=False) - def music_studio_stylesheet() -> FileResponse: + def music_studio_stylesheet(request: Request) -> FileResponse: + request.app.state.container.extension_runtime.require_loaded(EXTENSION_ID) return FileResponse( ASSET_DIRECTORY / "music-studio.css", media_type="text/css", @@ -26,7 +29,8 @@ def music_studio_stylesheet() -> FileResponse: ) @application.get("/console/assets/music-studio.js", include_in_schema=False) - def music_studio_script() -> FileResponse: + def music_studio_script(request: Request) -> FileResponse: + request.app.state.container.extension_runtime.require_loaded(EXTENSION_ID) return FileResponse( ASSET_DIRECTORY / "music-studio.js", media_type="text/javascript", diff --git a/src/agentmesh/packs/music_studio/extension.py b/src/agentmesh/packs/music_studio/extension.py new file mode 100644 index 0000000..d63c0fb --- /dev/null +++ b/src/agentmesh/packs/music_studio/extension.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path +from typing import cast + +from fastapi import FastAPI + +from agentmesh.application.artifact_services import ArtifactService +from agentmesh.application.business_object_services import BusinessObjectService +from agentmesh.application.ports import UnitOfWorkFactory +from agentmesh.application.registry_services import AgentRegistryService +from agentmesh.application.services import TaskApplicationService +from agentmesh.extensions.sdk import ( + CoreServiceKey, + ExtensionContext, + ExtensionHealth, + ExtensionManifest, + ExtensionServices, + ExtensionWorkspace, + RuntimeExtensionDefinition, +) +from agentmesh.features import Feature +from agentmesh.packs.music_studio.definition import DEFINITION, PACK_VERSION +from agentmesh.packs.music_studio.runtime import MusicStudioService + +EXTENSION_ID = "agentmesh.music-studio" +SERVICE_KEY = "studio" + + +def _create_services(context: ExtensionContext) -> dict[str, object]: + return { + SERVICE_KEY: MusicStudioService( + uow_factory=cast( + UnitOfWorkFactory, + context.require(CoreServiceKey.UNIT_OF_WORK_FACTORY), + ), + task_service=cast( + TaskApplicationService, + context.require(CoreServiceKey.TASKS), + ), + registry_service=cast( + AgentRegistryService, + context.require(CoreServiceKey.AGENT_REGISTRY), + ), + business_object_service=cast( + BusinessObjectService, + context.require(CoreServiceKey.BUSINESS_OBJECTS), + ), + artifact_service=cast( + ArtifactService, + context.require(CoreServiceKey.ARTIFACTS), + ), + tenant_id=context.tenant_id, + ) + } + + +def _register_api(application: FastAPI) -> None: + # Imports stay scenario-local and happen only while composing the HTTP surface. + from agentmesh.packs.music_studio.console import register_music_studio_console + from agentmesh.packs.music_studio.routes import router + + application.include_router(router) + register_music_studio_console(application) + + +def _health(services: ExtensionServices) -> ExtensionHealth: + services.require(SERVICE_KEY) + asset_directory = Path(__file__).with_name("assets") + expected = {"music-studio.html", "music-studio.css", "music-studio.js"} + missing = sorted(name for name in expected if not (asset_directory / name).is_file()) + if missing: + return ExtensionHealth( + status="unhealthy", + message="Missing workspace assets: " + ", ".join(missing), + ) + return ExtensionHealth(status="ready") + + +EXTENSION = RuntimeExtensionDefinition( + manifest=ExtensionManifest( + identifier=EXTENSION_ID, + name="AgentMesh Music Studio", + version=PACK_VERSION, + description="A governed multi-Agent studio for producing and reviewing original music.", + required_core_services=( + CoreServiceKey.UNIT_OF_WORK_FACTORY.value, + CoreServiceKey.TASKS.value, + CoreServiceKey.AGENT_REGISTRY.value, + CoreServiceKey.BUSINESS_OBJECTS.value, + CoreServiceKey.ARTIFACTS.value, + ), + provided_services=(SERVICE_KEY,), + required_features=( + Feature.ARTIFACT_SERVICE.value, + Feature.COMPANY_MODEL.value, + Feature.BUSINESS_OBJECTS.value, + Feature.COMPANY_PACKS.value, + ), + permissions=("company:manage", "task:create", "task:operate"), + workspaces=( + ExtensionWorkspace( + route="/music-studio", + name="Music Studio", + asset_prefix="/console/assets/music-studio", + ), + ), + external_writes_enabled=False, + ), + service_factory=_create_services, + api_registrar=_register_api, + company_templates=(DEFINITION,), + health_probe=_health, +) + +__all__ = ["EXTENSION", "EXTENSION_ID", "SERVICE_KEY"] diff --git a/src/agentmesh/packs/music_studio/routes.py b/src/agentmesh/packs/music_studio/routes.py index 712124d..47a208f 100644 --- a/src/agentmesh/packs/music_studio/routes.py +++ b/src/agentmesh/packs/music_studio/routes.py @@ -10,6 +10,7 @@ from agentmesh.api.security import PrincipalDependency, require_permission from agentmesh.domain.identity import Permission from agentmesh.features import Feature +from agentmesh.packs.music_studio.extension import EXTENSION_ID, SERVICE_KEY from agentmesh.packs.music_studio.runtime import MusicStudioService router = APIRouter( @@ -78,7 +79,13 @@ class MusicProjectResultResponse(BaseModel): def get_service(request: Request) -> MusicStudioService: - return request.app.state.container.music_studio_service + service = request.app.state.container.extension_runtime.require_service( + EXTENSION_ID, + SERVICE_KEY, + ) + if not isinstance(service, MusicStudioService): + raise TypeError("Music Studio extension returned an invalid service") + return service ServiceDependency = Annotated[MusicStudioService, Depends(get_service)] diff --git a/tests/conftest.py b/tests/conftest.py index a9143f9..50e4900 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,9 @@ from agentmesh.application.services import RunExecutionService, TaskApplicationService from agentmesh.application.tool_services import ToolInvocationService from agentmesh.bootstrap import ApplicationContainer +from agentmesh.extensions.builtin import RUNTIME_EXTENSION_REGISTRY +from agentmesh.extensions.runtime import ExtensionRuntime +from agentmesh.extensions.sdk import CoreServiceKey, ExtensionContext from agentmesh.features import FeatureGateSet from agentmesh.integrations.credentials import EnvironmentSecretValueProvider from agentmesh.orchestration.agent import ( @@ -44,7 +47,6 @@ DeterministicAgentExecutor, ) from agentmesh.orchestration.workflow import LangGraphWorkflowRunner -from agentmesh.packs.music_studio.runtime import MusicStudioService from tests.fakes import ( AlwaysReady, InMemoryOfficePlacementStore, @@ -312,6 +314,22 @@ def application_container( ), ), ) + container_feature_gates = FeatureGateSet.from_config("full") + extension_runtime = ExtensionRuntime.load( + RUNTIME_EXTENSION_REGISTRY, + ExtensionContext( + tenant_id="test-tenant", + services={ + CoreServiceKey.UNIT_OF_WORK_FACTORY.value: uow_factory, + CoreServiceKey.TASKS.value: task_service, + CoreServiceKey.AGENT_REGISTRY.value: registry_service, + CoreServiceKey.BUSINESS_OBJECTS.value: business_object_service, + CoreServiceKey.ARTIFACTS.value: artifact_service, + }, + ), + container_feature_gates, + "agentmesh.music-studio", + ) return ApplicationContainer( task_service=task_service, planning_service=planning_service, @@ -323,7 +341,7 @@ def application_container( budget_service=budget_service, resolution_service=resolution_service, readiness_probe=AlwaysReady(), - feature_gates=FeatureGateSet.from_config("full"), + feature_gates=container_feature_gates, identity_service=IdentityService(enabled=False, tenant_id="test-tenant"), identity_administration_service=IdentityAdministrationService( uow_factory=uow_factory, @@ -384,12 +402,5 @@ def application_container( artifact_service=artifact_service, tenant_id="test-tenant", ), - music_studio_service=MusicStudioService( - uow_factory=uow_factory, - task_service=task_service, - registry_service=registry_service, - business_object_service=business_object_service, - artifact_service=artifact_service, - tenant_id="test-tenant", - ), + extension_runtime=extension_runtime, ) diff --git a/tests/test_runtime_extensions.py b/tests/test_runtime_extensions.py new file mode 100644 index 0000000..9af93fa --- /dev/null +++ b/tests/test_runtime_extensions.py @@ -0,0 +1,205 @@ +from collections.abc import Mapping +from dataclasses import replace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentmesh.api.app import create_app +from agentmesh.bootstrap import ApplicationContainer +from agentmesh.extensions.builtin import RUNTIME_EXTENSION_REGISTRY +from agentmesh.extensions.runtime import ExtensionRuntime, RuntimeExtensionRegistry +from agentmesh.extensions.sdk import ( + ExtensionContext, + ExtensionHealth, + ExtensionManifest, + ExtensionServices, + ExtensionWorkspace, + InvalidRuntimeExtension, + RuntimeExtensionDefinition, + RuntimeExtensionUnavailable, +) +from agentmesh.features import Feature, FeatureGateSet + + +def _definition( + *, + identifier: str = "example.extension", + route: str = "/example", + factory: object | None = None, + stop: object | None = None, +) -> RuntimeExtensionDefinition: + def create_services(_: ExtensionContext) -> Mapping[str, object]: + if callable(factory): + return factory() + return {"worker": object()} + + def register_api(_: FastAPI) -> None: + return None + + def stop_services(services: ExtensionServices) -> None: + if callable(stop): + stop(services) + + return RuntimeExtensionDefinition( + manifest=ExtensionManifest( + identifier=identifier, + name="Example", + version="1.2.3", + description="Example trusted extension", + required_core_services=("tasks",), + provided_services=("worker",), + required_features=(Feature.ARTIFACT_SERVICE.value,), + workspaces=(ExtensionWorkspace(route=route, name="Example"),), + ), + service_factory=create_services, + api_registrar=register_api, + health_probe=lambda _: ExtensionHealth(status="ready"), + stop_callback=stop_services, + ) + + +def test_manifest_and_registry_reject_incompatible_or_ambiguous_extensions() -> None: + with pytest.raises(InvalidRuntimeExtension, match="Unsupported runtime extension API"): + ExtensionManifest( + identifier="example.invalid", + name="Invalid", + version="1.0.0", + description="Invalid API", + required_core_services=(), + provided_services=("worker",), + api_version="9.9", + ) + + registry = RuntimeExtensionRegistry((_definition(),)) + with pytest.raises(InvalidRuntimeExtension, match="workspace route"): + registry.register(_definition(identifier="another.extension")) + + +def test_registry_discovers_installed_trusted_entry_points( + monkeypatch: pytest.MonkeyPatch, +) -> None: + definition = _definition() + + class FakeEntryPoint: + name = "example" + + @staticmethod + def load() -> RuntimeExtensionDefinition: + return definition + + monkeypatch.setattr( + "agentmesh.extensions.runtime.entry_points", + lambda *, group: [FakeEntryPoint()] if group == "agentmesh.runtime_extensions" else [], + ) + + registry = RuntimeExtensionRegistry.discover() + assert registry.get("example.extension") is definition + + +def test_api_registration_rejects_core_route_collisions() -> None: + application = FastAPI() + + @application.get("/health") + def core_health() -> dict[str, str]: + return {"status": "ok"} + + def conflicting_api(app: FastAPI) -> None: + @app.get("/health") + def extension_health() -> dict[str, str]: + return {"status": "conflict"} + + definition = replace( + _definition(route="/health"), + api_registrar=conflicting_api, + ) + registry = RuntimeExtensionRegistry((definition,)) + with pytest.raises(InvalidRuntimeExtension, match="conflicting route '/health'"): + registry.register_api(application) + + +def test_runtime_loads_namespaced_services_reports_features_and_stops_once() -> None: + stopped: list[tuple[str, ...]] = [] + registry = RuntimeExtensionRegistry( + (_definition(stop=lambda services: stopped.append(services.keys())),) + ) + runtime = ExtensionRuntime.load( + registry, + ExtensionContext(tenant_id="tenant", services={"tasks": object()}), + FeatureGateSet.from_config("minimal"), + "example.extension", + ) + + assert runtime.require_service("example.extension", "worker") is not None + status = runtime.statuses()[0] + assert status.enabled is True + assert status.health == "degraded" + assert status.missing_features == (Feature.ARTIFACT_SERVICE.value,) + assert status.service_keys == ("worker",) + + runtime.close() + runtime.close() + assert stopped == [("worker",)] + + +def test_disabled_and_unknown_extensions_fail_closed() -> None: + registry = RuntimeExtensionRegistry((_definition(),)) + disabled = ExtensionRuntime.load( + registry, + ExtensionContext(tenant_id="tenant", services={}), + FeatureGateSet.from_config("minimal"), + "", + ) + assert disabled.statuses()[0].health == "disabled" + with pytest.raises(RuntimeExtensionUnavailable, match="disabled"): + disabled.require_service("example.extension", "worker") + + with pytest.raises(InvalidRuntimeExtension, match="Unknown enabled"): + ExtensionRuntime.load( + registry, + ExtensionContext(tenant_id="tenant", services={}), + FeatureGateSet.from_config("minimal"), + "missing.extension", + ) + + +def test_service_factory_must_match_declared_surface() -> None: + registry = RuntimeExtensionRegistry( + (_definition(factory=lambda: {"unexpected": object()}),) + ) + with pytest.raises(InvalidRuntimeExtension, match="must provide exactly"): + ExtensionRuntime.load( + registry, + ExtensionContext(tenant_id="tenant", services={"tasks": object()}), + FeatureGateSet.from_config("minimal"), + "example.extension", + ) + + +def test_extension_api_discloses_status_and_disabled_workspace_fails_closed( + application_container: ApplicationContainer, +) -> None: + with TestClient(create_app(application_container)) as client: + response = client.get("/api/v1/extensions") + assert response.status_code == 200 + music = response.json()[0] + assert music["identifier"] == "agentmesh.music-studio" + assert music["enabled"] is True + assert music["provided_services"] == ["studio"] + assert music["loaded_services"] == ["studio"] + assert music["workspaces"][0]["route"] == "/music-studio" + + application_container.extension_runtime = ExtensionRuntime.load( + RUNTIME_EXTENSION_REGISTRY, + ExtensionContext(tenant_id="test-tenant", services={}), + application_container.feature_gates, + "", + ) + with TestClient(create_app(application_container)) as client: + status = client.get("/api/v1/extensions").json()[0] + assert status["health"] == "disabled" + assert status["provided_services"] == ["studio"] + assert status["loaded_services"] == [] + disabled = client.get("/music-studio") + assert disabled.status_code == 503 + assert disabled.json()["code"] == "runtime_extension_unavailable"