Skip to main content
This page is for authors writing non-trivial agent halves. It walks the runtime path top to bottom: how the plugin host starts your code, what the unit is called and where it logs, how IPC frames travel, how capabilities gate every privileged call, and how the sandbox and restart policy behave.

Subprocess model

Every third-party agent plugin runs as its own process, started by systemd from a generated unit inside the shared ados-plugins.slice cgroup slice. There is no in-process tier for third parties. Built-in plugins that declare isolation: inprocess run inside the agent and skip everything on this page.
The plugin host (ados-plugin-host.service) owns the plugin’s Unix socket, renders the unit, and is a hard dependency of every plugin unit.

Unit name

The unit name is ados-plugin-<id with dots replaced by hyphens>-<8 hex>.service. The 8 hex digits are the first 4 bytes of sha256 of the exact plugin id, so two ids that sanitize to the same string (com.example.thermal and com.example-thermal) never share a unit. com.example.thermal-lepton becomes ados-plugin-com-example-thermal-lepton-1b3dd913.service. Find yours on the node:
Each service a plugin declares under agent.contributes.services gets its own unit, ados-plugin-<sanitized id>-<8 hex>-<service name>.service, in the same slice and with the same sandbox as the main unit. Units are written to /etc/systemd/system/.

Logs

The unit appends stdout and stderr to /var/log/ados/plugins/<sanitized id>-<8 hex>.log, for example /var/log/ados/plugins/com-example-thermal-lepton-1b3dd913.log. A declared service logs to <sanitized id>-<8 hex>-<service name>.log in the same directory. Plugin output does not go to the journal. ados plugin logs asks the agent for the plugin’s log path, so you never build the name yourself:
--service reads a declared service’s log; an unknown service name exits with the list of declared services. The supervisor trims any plugin log past 32 MiB in place, keeping its tail.

Environment

The unit sets a small, fixed environment: The capability token, agent id and data directory are not in the environment. The host writes them to a root-owned 0600 file and the unit loads it with LoadCredential=, so the process reads it as $CREDENTIALS_DIRECTORY/ados-plugin-token, visible to that plugin’s user alone. The host rewrites it on every start and every token rotation. The Python runner reads it for you; a Rust plugin’s SDK runner does the same. The plugin id is the runner’s first positional argument. The paths the plugin writes to arrive on the ctx object (see State and storage).

IPC envelope

The plugin host listens on the socket before the plugin starts. The plugin connects on startup and speaks length-prefixed msgpack frames: a 4-byte big-endian length, then the msgpack-encoded envelope. Frames over 4 MiB are refused.
Decoded, an envelope carries these fields (the wire form uses compact msgpack keys, and the GCS postMessage bridge carries the same logical fields):
The host never trusts the capability field on the wire. It resolves the required capability from the method name on every request, then checks the granted set carried by the token. Forging the field fails with a capability-denied error.

The plugin class and lifecycle hooks

A plugin’s agent half is a class named by the manifest entrypoint (module:Class, or a Plugin class in a file-path entrypoint). The runner imports it, constructs it, and calls async lifecycle hooks if they are defined. The hooks are duck-typed; implement the ones you need:
At start the runner calls on_install, on_enable, on_configure (with the static config) and then on_start, each only if present. on_stop and on_disable run when the unit receives SIGTERM. See Lifecycle for the full order. ctx is a PluginContext built by the runner. It exposes the capability-gated facades ctx.events, ctx.mavlink, ctx.msp, ctx.peripheral_manager, ctx.camera, ctx.video, ctx.flight, ctx.buttons, ctx.vision, ctx.telemetry, ctx.config_kv (live key-value config), ctx.process, ctx.lifecycle and ctx.tools, plus ctx.config (the static config dict), ctx.plugin_id, ctx.agent_id, ctx.data_dir, ctx.config_dir, ctx.temp_dir and ctx.log. You receive it as an argument and never construct it; the reference plugins in ADOSExtensions/extensions/ are working examples.

Capability enforcement

Capabilities are enforced in the plugin host, not in the plugin. Each method maps to a required capability; the host checks the token’s granted set before the handler runs and rejects an ungranted caller before any work happens. In the plugin, the call raises CapabilityDenied:
Operators can revoke a capability at any time from Mission Control or with ados plugin perms <id> --revoke <cap>. A grant or revoke re-renders the unit and restarts the plugin, so both the wire checks and the sandbox follow the new set. The capability list and risk levels live in Permissions.

Sandbox and identity

Four mechanisms isolate a plugin beyond the wire checks.
  • Its own system user. Every plugin runs as ados-plg-<8 hex> (the same hash as the unit name) with primary group ados-plugins. Distinct users stop one plugin from reading another’s /proc/<pid>/environ, tracing it, or reaching its socket. The user is created at install and removed with the plugin.
  • Grant-driven device policy. With no hardware capability granted the unit gets PrivateDevices=yes, a private /dev holding only pseudo-devices. Each granted hardware capability (hardware.uart, hardware.i2c, hardware.spi, hardware.gpio, hardware.usb, hardware.usb.uvc, hardware.camera.csi) switches to DevicePolicy=closed with exactly the DeviceAllow= lines it names. A hardware.uart grant still cannot open the flight controller’s configured serial port.
  • Network and filesystem. Without network.outbound the unit allows only AF_UNIX sockets and denies all IP addresses. No plugin binds an inet port unless a declared service holds network.listen, and then only its declared ports. /run/ados is an empty read-only tmpfs with only the plugin’s own socket directory and the log socket bound back in, and the agent’s credential files under /etc/ados are inaccessible. filesystem.host opens /srv, /mnt, /media and /boot.
  • Declared services and HTTP. agent.contributes.services render extra units that run as the plugin’s user under the same sandbox. agent.http: true gives the plugin an HTTP socket the agent proxies. shared_topics and payloads are manifest-level declarations; see the Manifest reference and Contribution points.

cgroup limits

The unit carries resource caps from the manifest’s agent.resources block: MemoryMax from max_ram_mb, CPUQuota from max_cpu_percent, and TasksMax from max_pids. The kernel enforces them. A memory breach is OOM-killed, a CPU breach throttles, and a tasks breach refuses new threads or processes. The shared slice runs at IOWeight=10, so a plugin loses every contended disk block to the flight services. Budgets and how to read the live counters are on Performance and budgets.

Restart policy

There is no start-rate limit: a crashing plugin is retried every 2 s indefinitely and never latches failed, so a plugin whose host socket is not up yet recovers on its own. A clean exit (status 0) is not restarted; if your plugin should run indefinitely, do not return from on_start until on_stop is called. Plugin units carry no watchdog.

Generated unit

Units are generated at install and re-rendered on every grant and revoke. This is the pinned unit for a Rust plugin com.example.rustplug granted hardware.i2c, network.outbound and filesystem.host:
The block after # ---- capability sandbox is re-rendered on every grant or revoke. ExecStartPre=+... guard-load runs privileged before the plugin and fails the start if the guard cannot load. NoExecPaths=/ with the ExecPaths= allowlist means the plugin can execute only its own install tree and the shared libraries, never /bin/sh. A Python plugin differs in two lines: ExecStart=/opt/ados/venv/bin/ados-plugin-runner <id>, and ExecPaths= also lists the runner’s virtualenv. The runner loads the manifest, puts the plugin source on sys.path, imports the entrypoint class, connects to the socket, and dispatches into your hooks. A runtime: rust plugin’s binary is executed directly.

Updates and restarts

Plugins are not hot-reloaded. Installing a new version stops the old process, re-renders the unit, and starts fresh. The data directory survives; see Versioning and updates.

Debugging tips

  • ados plugin logs <id> --follow tails the plugin’s log.
  • systemctl status 'ados-plugin-<sanitized id>-*' shows the live unit state of the main unit and its services.
  • systemctl cat 'ados-plugin-<sanitized id>-*' prints the generated units.
  • ados plugin info <id> prints granted permissions and install state.
For the symptom-to-fix list (install failures, capability denials, IPC handshake errors), see Troubleshooting.

Next steps

GCS plugin deep dive

How the Mission Control half runs, talks to the host and renders.

Performance and budgets

Resource caps, throttling, memory pressure and profiling.
Last modified on October 11, 2026