Skip to content

0035 — Legion plugin loader: out-of-tree workers via entry points

  • Status: Accepted
  • Date: 2026-05-18
  • Deciders: @Sinidious
  • Related issues / PRs: ROADMAP v2.0, supersedes the "plugins are in-tree only" implication of ADR-0009 and extends ADR-0028

Context

Every Legion worker that ships with CAESAR today (calculator, web_search, calendar_read, notify, memory_recall, semantic_recall) lives in caesar/legion/* and is registered by name inside _build_inprocess_worker in caesar/praetor/app.py. To add a new worker an operator must fork the repo, add a module, edit the factory, and rebuild — the kind of friction that kills community extension.

v1.3 already opened the gate by adding non-HA tool workers (ADR-0028) and generalising the policy engine to non-HA ToolCalls. v2.0 closes it: workers should be third-party Python packages that an operator pip installs and that Praetor wires up automatically.

What we want from the loader:

  • No fork required. A community-published caesar-plugin-foo package on PyPI is a one-line install.
  • The existing Worker base class stays the contract. The bus protocol, the registration shape, the policy hook — all unchanged.
  • Operator-visible config. A plugin's settings show up in the same .env namespace the operator already knows.
  • Same trust posture as v1.x. CAESAR has been single-host, single-operator since v1.0 (see SECURITY-MODEL.md). A plugin is not a sandbox; it is a Python dependency the operator chose to install — and earns the same trust as requests or pydantic.
  • No breaking change to in-tree workers. Anything that currently works through _build_inprocess_worker keeps working.

Decision

CAESAR will load out-of-tree workers via Python entry points in a new group, caesar.plugins, with each entry resolving to a PluginFactory callable that returns a Worker instance given a PluginContext. Plugins run same-process with Praetor — no subprocess or container isolation.

The contract

A plugin package declares one entry point per worker:

# In the plugin's pyproject.toml
[project.entry-points."caesar.plugins"]
mqtt_publisher = "caesar_plugin_mqtt:build_worker"

The referenced callable is a PluginFactory:

from caesar.legion.worker import Worker
from caesar.plugins.context import PluginContext

def build_worker(ctx: PluginContext) -> Worker:
    return MqttPublisherWorker(
        bus=ctx.bus,
        settings=ctx.plugin_settings,  # the plugin's pydantic model
        audit=ctx.audit,
    )

The returned Worker subclass keeps the existing class-var contract already enforced by caesar/legion/worker.py:

Attribute Type Required? Notes
worker_id str Yes Unique across both in-tree and plugin workers
capabilities list[str] Yes Strings the policy engine + bus protocol both already use
version str Yes Semver of the worker itself
requires_caesar str (semver constraint) Yes (new) e.g. ">=2.0,<3.0". Praetor refuses to load if it doesn't satisfy.
settings_model type[BaseModel] \| None Optional Pydantic model describing the plugin's config

Config injection

The plugin's settings_model (when declared) is wired into the global CaesarSettings under a plugins.<worker_id>.* namespace, which the existing nested-env-var mechanism (ADR-0017) maps from CAESAR_PLUGINS__<WORKER_ID>__*:

# Operator's .env
CAESAR_PLUGINS__MQTT_PUBLISHER__BROKER_URL=mqtt://localhost:1883
CAESAR_PLUGINS__MQTT_PUBLISHER__TOPIC_PREFIX=caesar/

PluginContext.plugin_settings is the populated, validated Pydantic model — the plugin never reads os.environ directly.

Lifecycle

Plugins enter the existing lifespan path:

  1. Startup: scan importlib.metadata.entry_points(group="caesar.plugins"), validate each (requires_caesar, capability uniqueness, settings schema), instantiate each via its factory, append to the existing workers list. The current for name in settings.legion.inprocess_workers loop in praetor/app.py keeps working unchanged; the plugin loader sits next to it.
  2. Shutdown: the existing for worker in reversed(workers): await worker.stop() covers plugins for free.

Operator controls

Two opt-out knobs:

# Disable plugin loading entirely (default: enabled when bus is enabled)
CAESAR_PLUGINS__ENABLED=true|false

# Optional allowlist; when set, only entries whose distribution name
# is in the list load. Operators paranoid about which `pip install`
# accidentally exposes a worker can pin the surface.
CAESAR_PLUGINS__ALLOWLIST=["caesar-plugin-mqtt", "caesar-plugin-tts"]

A plugin that fails any validation (incompatible requires_caesar, capability collision with another plugin or in-tree worker, missing required setting) logs a clear plugin.load.rejected audit row and is skipped — Praetor does not abort startup. One bad plugin must not take CAESAR down.

Capability ownership

Plugin-declared capabilities flow through the same policy allow-list path as in-tree capabilities. The policy YAML cannot tell the difference between light.turn_on (HA, in-tree) and mqtt.publish (hypothetical plugin) — both must appear in the operator's allowed_services (or equivalent generalised form from ADR-0028) to be invocable. Plugins do not bypass the Policy Engine.

Alternatives considered

  • Subprocess per plugin. Process-level isolation, plugins don't see secrets, IPC over NATS. Rejected for v2.0 because: (a) the startup cost per worker compounds on memory-constrained homelab hardware; (b) plugins would need their own venv management, multiplying deployment burden; (c) the IPC schema becomes API surface that every plugin must speak. The threat we'd be defending against is "I pip installed a malicious package" — and pip itself isn't sandboxed, so the marginal protection is small while the operational cost is high. Revisit if a future trust posture (multi-tenant, untrusted plugin source) emerges.
  • Container per plugin. Maximum isolation; supports non-Python plugins. Rejected: requires Docker on every CAESAR host, contradicting the "single pip install, single binary on a Raspberry Pi" install story v1.4 committed to. Operationally crushing for a homelab.
  • File-system drop-in plugins (a plugins/ directory of .py files). Simpler than entry points — operator drops a file in, Praetor imports it. Rejected because (a) it bypasses the pip trust chain (no SBOM, no Dependabot, no PyPI trusted-publisher attestation from SR-011 fu); (b) it encourages copy-paste plugins that don't get version-pinned; (c) every other Python ecosystem has converged on entry points for this exact problem.
  • Per-plugin Python venvs auto-managed by Praetor. Considered for dependency-conflict isolation. Rejected — the operator already manages CAESAR's venv; expecting Praetor to also manage child venvs makes the install story dramatically harder. Plugins that need exotic dependencies are responsible for declaring them cleanly in their pyproject.toml; pip's resolver is enough.
  • Do nothing. Community contributions stay forks; the upstream worker set grows slowly via merges. The bus protocol is community-ready (ADR-0009, NATS is a public spec), but without a loader the friction beats almost every contribution.

Consequences

Positive

  • Community workers without a fork. pip install caesar-plugin-foo and the operator's .env is all it takes. Doc page (#165) gives a step-by-step.
  • The existing Worker base class is the public API. Nothing new to maintain — the bus protocol, the registration shape, the policy hook all stay put.
  • Plugin config in the same .env operators already use. CAESAR_PLUGINS__<worker_id>__* is consistent with every other config namespace.
  • PyPI's existing trust chain. Combined with SR-011 follow-up's trusted-publisher attestation, an operator can verify a plugin's build provenance with gh attestation verify before installing.
  • No isolation surprises. Same-process plugins use the same Bus/Audit/Settings singletons as in-tree workers. Debugging looks identical.

Negative

  • Trust = code trust. A malicious plugin runs with Praetor's privileges, including the HA token and LLM API keys. We document this explicitly in the "Build a CAESAR plugin" page and in SECURITY-MODEL.md — operators who want isolation must run CAESAR itself in a container.
  • Capability collisions become a startup failure. A plugin claiming a capability already in use by another plugin or in-tree worker fails to load. Diagnosed by a clear plugin.load.rejected audit row and a startup warn log, but it is a new failure mode.
  • Plugin version compatibility is the plugin's problem. A plugin declares requires_caesar = ">=2.0,<3.0"; if CAESAR ships v3.0 and a plugin author hasn't updated, the plugin silently disappears. The plugin docs warn loudly about pinning.

Neutral

  • No schema change. The audit_log table already carries plugin.load.* events the same way it carries every other one.
  • The caesar.plugins entry-point group name is now part of CAESAR's public API surface. Renames are breaking changes for every plugin in the wild.
  • Per-plugin distribution naming is convention only. We recommend caesar-plugin-<name> for discoverability (the marketplace pattern is a curated list on GitHub Topics), but Praetor doesn't enforce naming.

References

  • ADR-0009 — the Legion bus protocol plugins ride.
  • ADR-0017 — the nested-env-var mechanism plugin settings reuse.
  • ADR-0026 — task routing, which plugins gain access to via PluginContext.gateway if they declare an LLM dependency.
  • ADR-0028 — the generalised tool-call policy that plugin capabilities flow through.
  • SECURITY-MODEL.md — the single-operator trust posture that justifies same-process loading.
  • Python packaging: Entry points.