4 Commits

Author SHA1 Message Date
Agent Zero b04cda774b fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers
Check Cross-Plugin Imports / check (push) Has been cancelled
Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher.
2026-08-23 15:30:12 +02:00
Agent Zero 982b4c9353 fix(arch-003): active-manifests available to every authenticated user 2026-08-23 15:09:39 +02:00
Agent Zero 1d6152fb82 fix(arch-001,arch-002): permissions before on_activate; activate once per process 2026-08-23 15:04:51 +02:00
Agent Zero 337d78ef53 merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable) 2026-08-23 14:47:52 +02:00
7 changed files with 62 additions and 17 deletions
+6 -1
View File
@@ -36,7 +36,12 @@ class EventBus:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list) self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None: def subscribe(self, event_name: str, handler: EventHandler) -> None:
"""Subscribe a handler to an event.""" """Subscribe a handler to an event.
Idempotent: subscribing the same handler twice is a no-op
(ARCH-020) so double activation cannot fire handlers twice.
"""
if handler not in self._handlers[event_name]:
self._handlers[event_name].append(handler) self._handlers[event_name].append(handler)
def unsubscribe(self, event_name: str, handler: EventHandler) -> None: def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
+4 -1
View File
@@ -121,11 +121,14 @@ class TriggerDispatcher:
"""Query DB for active automations matching *event_name* and dispatch.""" """Query DB for active automations matching *event_name* and dispatch."""
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
# None-check FIRST — accessing attributes on the contract before the
# check crashed with AttributeError when automation was inactive
# (ARCH-029/041).
automation_contract = get_contract("automation") automation_contract = get_contract("automation")
AutomationDefinition = automation_contract.Automation # noqa: N806
if automation_contract is None: if automation_contract is None:
logger.debug("Automation plugin not available — trigger skipped") logger.debug("Automation plugin not available — trigger skipped")
return return
AutomationDefinition = automation_contract.Automation # noqa: N806
factory = get_session_factory() factory = get_session_factory()
tenant_id = payload.get("tenant_id") tenant_id = payload.get("tenant_id")
+9 -8
View File
@@ -284,22 +284,23 @@ async def lifespan(app: FastAPI):
logger.info(f"Plugin {name} is inactive — skipping activation") logger.info(f"Plugin {name} is inactive — skipping activation")
continue continue
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage # Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
# RLS fail-closed requires app.current_tenant_id for tenant-table writes. # the first tenant's RLS context satisfies fail-closed RLS for any
# Plugin activation may fail on duplicate cron job inserts — this is harmless # tenant-table writes during activation. Plugins that need per-tenant
# since cron jobs already exist from previous startups. # data must seed it themselves (e.g. via the default-tenant mechanism).
# Calling on_activate once prevents duplicate event listeners, cron
# jobs, mini-apps and other contributions at multi-tenant startups.
plugin_activated = False plugin_activated = False
for tenant_id in all_tenant_ids: if all_tenant_ids:
try: try:
async with async_session() as plugin_db: async with async_session() as plugin_db:
await set_tenant_context(plugin_db, tenant_id) await set_tenant_context(plugin_db, all_tenant_ids[0])
await plugin.on_activate(plugin_db, container, event_bus) await plugin.on_activate(plugin_db, container, event_bus)
await plugin_db.flush() await plugin_db.flush()
await plugin_db.commit() await plugin_db.commit()
plugin_activated = True plugin_activated = True
except Exception as exc: except Exception as exc:
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}") logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
break
if plugin_activated: if plugin_activated:
plugin_record.status = "active" plugin_record.status = "active"
+18 -2
View File
@@ -59,20 +59,31 @@ class ContractRegistry:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {} cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set() cls._instance._loaded: set[str] = set()
cls._instance._unregistered: set[str] = set()
return cls._instance return cls._instance
# ─── registration ─── # ─── registration ───
def register(self, plugin_name: str, contract: Any) -> None: def register(self, plugin_name: str, contract: Any) -> None:
"""Register or replace a contract for a plugin.""" """Register or replace a contract for a plugin.
Clears the unregistered marker so a later deactivation can be
distinguished from a fresh lazy-load again (ARCH-014).
"""
self._unregistered.discard(plugin_name)
self._contracts[plugin_name] = contract self._contracts[plugin_name] = contract
self._loaded.add(plugin_name) self._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name) logger.debug("Contract registered for plugin '%s'", plugin_name)
def unregister(self, plugin_name: str) -> None: def unregister(self, plugin_name: str) -> None:
"""Remove a contract (e.g. when the plugin is deactivated).""" """Remove a contract (e.g. when the plugin is deactivated).
Marks the plugin as explicitly unregistered so later ``get_contract``
calls cannot resurrect the contract via lazy-loading (ARCH-014).
"""
self._contracts.pop(plugin_name, None) self._contracts.pop(plugin_name, None)
self._loaded.discard(plugin_name) self._loaded.discard(plugin_name)
self._unregistered.add(plugin_name)
# ─── lookup ─── # ─── lookup ───
@@ -85,6 +96,11 @@ class ContractRegistry:
if plugin_name in self._contracts: if plugin_name in self._contracts:
return self._contracts[plugin_name] return self._contracts[plugin_name]
# Explicitly unregistered (deactivated): never resurrect via
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
if plugin_name in self._unregistered:
return None
if plugin_name not in self._loaded: if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name) self._try_lazy_load(plugin_name)
+16
View File
@@ -608,6 +608,22 @@ class PluginRegistry:
for warning in perm_warnings: for warning in perm_warnings:
logger.warning(warning) logger.warning(warning)
# Register permissions and entity models BEFORE on_activate (ARCH-001 fix):
# the activation hook may already rely on its own permissions/entities being
# resolvable (e.g. tools declaring required_permission).
from app.core.permission_registry import (
get_permission_registry,
register_plugin_permissions,
)
if plugin.manifest.permissions:
register_plugin_permissions(name, plugin.manifest.permissions)
get_permission_registry()._active_plugins.add(name)
for entity_type, model_class in plugin.get_entity_models().items():
from app.services.entity_permission_service import register_entity_model
register_entity_model(entity_type, model_class)
# Call on_activate hook (registers event listeners) # Call on_activate hook (registers event listeners)
await plugin.on_activate(db, self._container, self._event_bus) await plugin.on_activate(db, self._container, self._event_bus)
+5 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_admin, require_permission from app.deps import require_admin, require_permission
from app.deps import get_current_user
from app.plugins.migration_runner import MigrationValidationError from app.plugins.migration_runner import MigrationValidationError
from app.services.plugin_service import get_plugin_service from app.services.plugin_service import get_plugin_service
@@ -94,7 +95,10 @@ async def check_plugin_updates(
@router.get("/active-manifests") @router.get("/active-manifests")
async def get_active_manifests( async def get_active_manifests(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:read")), # ARCH-003 fix: every authenticated user needs the UI manifests for the
# dynamic sidebar/routes — the data is pure UI metadata; actual data
# access stays protected by each endpoint's own permission.
current_user: dict = Depends(get_current_user),
): ):
"""Get UI manifests for all active plugins. """Get UI manifests for all active plugins.
+3 -3
View File
@@ -530,7 +530,7 @@ class TestAgentPermissions:
) as mock_resolve: ) as mock_resolve:
mock_resolve.return_value = _permissions(permissions=["mail:read"]) mock_resolve.return_value = _permissions(permissions=["mail:read"])
with patch( with patch(
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry", "app.ai.tool_registry.get_tool_registry",
return_value=tool_registry, return_value=tool_registry,
): ):
ctx = await resolve_agent_permissions( ctx = await resolve_agent_permissions(
@@ -575,7 +575,7 @@ class TestAgentPermissions:
) as mock_resolve: ) as mock_resolve:
mock_resolve.return_value = _permissions(permissions=["mail:read"]) mock_resolve.return_value = _permissions(permissions=["mail:read"])
with patch( with patch(
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry", "app.ai.tool_registry.get_tool_registry",
return_value=tool_registry, return_value=tool_registry,
): ):
ctx = await resolve_agent_permissions( ctx = await resolve_agent_permissions(
@@ -1015,7 +1015,7 @@ class TestContextBuilder:
]) ])
with patch( with patch(
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry", "app.ai.tool_registry.get_tool_registry",
return_value=tool_registry, return_value=tool_registry,
): ):
messages = await build_agent_context( messages = await build_agent_context(