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-foopackage 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
.envnamespace 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
requestsorpydantic. - No breaking change to in-tree workers. Anything that currently
works through
_build_inprocess_workerkeeps 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:
- 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 existingworkerslist. The currentfor name in settings.legion.inprocess_workersloop inpraetor/app.pykeeps working unchanged; the plugin loader sits next to it. - 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-fooand the operator's.envis 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
.envoperators 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 verifybefore 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.rejectedaudit 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_logtable already carriesplugin.load.*events the same way it carries every other one. - The
caesar.pluginsentry-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.gatewayif 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.