21 KiB
Termix plugin architecture
This is the contract between Termix core and a plugin. It is the reference for
anyone writing a plugin and the specification the runtime in
src/backend/plugins/ implements.
Status: the redesign lands across several steps. Everything marked A1 is built today. Later markers name the step that delivers the rest.
The shape of it
1. One tier
Every plugin, official or community, runs in-process in the Termix server and in the browser. There is no first-party allowlist, no worker tier, and no special case keyed on a plugin id anywhere in core.
This is not a sandbox. See What this protects.
2. SDK only
A plugin imports @termix/plugin-sdk, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
|---|---|
@termix/plugin-sdk/backend |
PluginContext, definePlugin(), PluginCapabilityError |
@termix/plugin-sdk/frontend |
The app object types (stubs until A7) |
@termix/plugin-sdk/manifest |
Manifest types, validation, the JSON schema |
@termix/plugin-sdk/capabilities |
The capability catalog |
@termix/plugin-sdk/testing |
Test helpers, incl. createFakeContext() |
Plugins never import from src/ or @/, and core never imports from
plugins/. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with the exceptions recorded under
legacy core imports.
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
implementation lives in core (src/backend/plugins/ctx.ts) and is injected at
activation, so a plugin cannot reach around it.
3. Capabilities are what a plugin's code may do
Declared in the manifest's capabilities array using domain:action names
from one catalog in the SDK. Every privileged SDK call checks the capability
and writes an audit line naming the plugin and the acting user.
Two things must both be true, and a grant for something the manifest never declared is ignored rather than honoured:
- the manifest declares it, and
- it is granted in
plugin_permission_grants.
That means widening a plugin's reach always requires a new manifest the user
can see. In 2.9.0 bundled plugins are granted everything they declare
automatically, with source: "bundled".
Adding a capability is one catalog entry (id, risk, i18n title and consequence text) plus the SDK method that checks it.
4. User permissions are what a user may do
These stay in core RBAC and keep their dotted spelling (hosts.view), so they
can never be confused with colon-separated capabilities.
A plugin declares short names in contributes.permissionGroup. Core registers
them, groups them under the plugin in the role editor, and only lets a plugin
set defaults for its own namespace: a defaultForRole entry naming a
permission the plugin does not declare is a validation error, not a silent
escalation. Permissions stored in a role survive the plugin being disabled.
Role defaults are applied once and the fact is recorded, so an admin who revokes one does not get it handed back on the next restart.
5. Data
Plugins own their tables through SDK table definitions and ship migrations for
sqlite, pg and mysql. Table names are prefixed p_<id with - as _>_. Legacy
core tables are adopted by rename in the plugin's first migration. ctx.kv is
for small key/value state. Core schema.ts ends up holding only core tables.
A3 delivers the table definitions and migration runner. A1 ships ctx.kv
only.
6. HTTP and WebSockets
Plugin routes live under /plugin-api/<id>/ and sockets under
/plugin-ws/<id>/, served by the main backend with core auth. No plugin gets
its own port or nginx block.
A4 delivers this. In A1 /plugin-api is mounted and auth-gated but has no
routes registered, so it returns 404. The bundled plugins still keep their
original URLs through the dispatchers in src/backend/database/routes/*-dispatch.ts,
and four still own ports of their own.
7. Settings
Plugins declare settings fields in the manifest with scope admin, user or
host. Core renders them from the schema (with an optional custom component),
stores values in plugin_settings, and encrypts secret fields. One Settings
screen has a Plugins section with a page per plugin; host settings render in
the host editor. A6.
8. UI
A plugin frontend is a built ESM bundle loaded at runtime by the core loader.
It registers everything through the app object: rail items, panels, tabs,
host editor sections, host actions and badges, context menu items, palette
entries, dashboard cards, homepage widgets, settings components, action slots,
login/2FA UI, SSH auth editors. The shell contains no plugin ids. A7.
9. Cross-plugin use
Through ctx.services (typed, versioned, RBAC-checked per call), ctx.events,
and frontend actions and slots. Dependencies are declared in the manifest:
dependencies are hard, optionalDependencies are soft. The loader activates
in dependency order. A plugin must keep working when an optional dependency is
missing.
10. Lifecycle
activate(ctx) creates all state. Everything registered through ctx or app
is disposed automatically on deactivate. See Lifecycle rules.
11. Tests
Each plugin has plugins/<id>/tests/backend and plugins/<id>/tests/frontend,
run by its own vitest config built on the SDK preset (A2). Core tests
(src/backend/tests, src/ui/tests) never test plugin code; they may test the
plugin runtime with fixture plugins.
Until A2, plugin tests still live under src/*/tests/plugins/.
12. Auth
Core keeps password login, sessions, API keys, trusted proxy login, Electron
auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through ctx.auth. A8.
13. Where plugins live
Official plugins live in plugins/<id>/ as npm workspace packages in this
monorepo for all of 2.9.0, and get split into their own repos later. Every
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a migration for all three dialects and a test proving existing data survives.
Manifest v2 reference
plugins/<id>/manifest.json. The directory name must equal id. Unknown
fields are rejected at every level: a typo'd contribute used to validate
clean and be silently dropped, which made a manifest describe something it did
not do.
{
"id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
"name": "Example",
"version": "1.0.0", // semver
"description": "What it does.",
"author": { "name": "You", "url": "https://..." },
"license": "MIT",
"repository": "https://...", // optional
"category": "Productivity", // see the enum below
"icon": "Sparkles", // optional: Lucide name or a bundled icon.svg
"engine": {
"termix": ">=2.9.0", // display only, never enforced
"api": "1", // the real compatibility gate
},
"capabilities": ["kv:own", "ui:surface"],
"dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation
"optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal
"provides": [
{
"service": "example.thing",
"version": "1.0.0",
"permission": "example.use",
},
],
"requires": [
{ "service": "other.thing", "versionRange": "^1.0.0", "optional": true },
],
"providesSecret": [
{ "key": "api-key", "permission": "example.secrets.share" },
],
"requiresSecret": [
{ "plugin": "other-plugin", "key": "api-key", "optional": true },
],
"contributes": {
"tabs": [
{
"id": "example",
"titleKey": "nav.example",
"icon": "Sparkles",
"openFrom": ["rail"],
},
],
"actions": [
{
"id": "example.open",
"titleKey": "k",
"handler": "open",
"permission": "example.use",
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissionGroup": {
"group": "example",
"permissions": ["example.use"],
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
},
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
"editorTab": "general",
},
},
"backend": "dist/backend.js", // default
"frontend": "dist/frontend.js", // default
"locales": "locales", // default
"platforms": ["linux", "win32", "darwin"], // optional
}
category is one of: Terminal, Files & Transfer, Infrastructure, Monitoring,
Networking, Access & Security, Productivity.
openFrom is one of: rail, host-context-menu, palette.
Cross-field rules the JSON schema cannot express, checked by parseManifest:
engine.apimust match the SDK major version this build implements.- Every
provides[].permission,providesSecret[].permissionandcontributes.actions[].permissionmust appear in the plugin's owncontributes.permissionGroup.permissions. A permission the catalog never sees is one no admin can grant, so the surface would be invisible rather than denied. requiresSecret[].pluginand both dependency maps must not name the plugin itself, and a plugin cannot be in bothdependenciesandoptionalDependencies.- Every
defaultForRoleentry must be a permission the plugin declares.
contributes.hostCapability, provides, requires, providesSecret and
requiresSecret are carried forward from v1 unchanged because the service and
secret registries read them at activation. A5, A6 and A7 reshape them.
Removed in v2: permissions (replaced by capabilities), sidecars, the
capabilities.{backend,frontend,electron,platforms} object (replaced by the
backend/frontend/platforms fields), contributes.dashboardCards,
contributes.settingsPanel, contributes.apiPrefix, and
process:transport-owner (the marker for the tier that no longer exists).
Capability catalog
Colon-separated, ordered worst first. The i18n keys resolve under
plugins.capabilities.<id>.title and .consequence in
src/ui/locales/en.json, and the copy describes the consequence to the person
deciding, not the mechanism.
| Capability | Risk | Meaning |
|---|---|---|
credentials:read |
critical | See plaintext secrets for any reachable host |
ssh:connect |
high | Run commands on hosts it is connected to |
process:spawn |
high | Run programs on the Termix server |
users:write |
high | Create and change user accounts |
auth:provide |
high | Add a login method or second factor |
system:tls |
high | Request and replace the server certificate |
hosts:write |
medium | Create and change hosts |
credentials:use |
medium | Connect using a host's stored credentials, without seeing them |
network:outbound |
medium | Make outbound requests |
network:serve |
medium | Open a listening port |
users:read |
medium | See usernames and roles, never hashes or 2FA state |
events:core |
medium | Subscribe to and emit core events carrying other users' data |
notify:send |
medium | Send notifications through configured channels |
audit:read |
medium | Read the audit log |
desktop:window |
medium | Open desktop windows (Electron only) |
hosts:read |
low | See the host list it can already see |
db:own |
low | Own tables |
kv:own |
low | Small key/value state |
secrets:own |
low | Its own encrypted secrets |
settings:read-core |
low | Read core server settings |
ui:surface |
low | Contribute tabs, panels and settings |
Rules
- One catalog, in
packages/plugin-sdk/src/capabilities.ts. Nothing else defines a capability. - Adding one is an entry here plus the SDK method that checks it. A capability nothing checks is decoration, and v1 shipped nineteen of those.
- A capability is checked where the privileged thing happens, not at activation. Activation-time checks say what a plugin might do; call-time checks say what it did.
events:coreis the exception that proves the shape: without it a plugin may only emit underplugin.<its id>., because otherwise it could publishhost.statusand drive the automations engine as though core had.
The ctx surface
Built per plugin in src/backend/plugins/ctx.ts and passed to activate.
| Member | Capability | Status |
|---|---|---|
ctx.pluginId, ctx.manifest |
none | A1 |
ctx.log.* |
none | A1 |
ctx.events.emit / .on |
events:core for core topics |
A1 |
ctx.kv.get/set/delete/list |
kv:own |
A1 |
ctx.registry.* |
none | A1 |
ctx.services.provide / .get |
per-service RBAC | A1 |
ctx.secrets.offer / .withdraw / .getShared |
per-secret RBAC | A1 |
ctx.disposables.add |
none | A1 |
ctx.asUser(userId, fn) |
none, always audited | A1 |
ctx.currentActor() |
none | A1 |
ctx.db |
db:own |
A3 |
ctx.http.route / ctx.ws |
network:serve |
A4 |
ctx.hosts.* |
hosts:read / hosts:write |
A4 |
ctx.ssh.* |
ssh:connect, credentials:use |
A4 |
ctx.settings.* |
settings:read-core |
A6 |
ctx.notify.* |
notify:send |
A6 |
ctx.auth.* |
auth:provide |
A8 |
ctx.fetch |
network:outbound |
A4 |
The actor
Never comes from plugin code. It is held in AsyncLocalStorage and set two
ways, with no third:
- A request. A4's HTTP and WebSocket middleware runs the handler as the user core already authenticated.
ctx.asUser(userId, fn)for background work with no request behind it. Always audited, and every core API inside still applies that user's RBAC.
A plugin cannot pass a user id to a guarded method and have it believed. This
is what closes the forged-caller bug the old worker broker had, where a worker
could set callerUserId on its own messages.
Lifecycle rules
- Everything is created inside
activate. No servers, timers, listeners or connections at module scope. ESM caches modules, so disable-then-enable re-runsactivateagainst the same module object: anything created at module scope is already torn down by the second call and never comes back. - Everything is released on deactivate. Registrations made through
ctxare tracked automatically. Anything the plugin creates itself goes inctx.disposables.add(fn). A plugin that keeps a port after deactivate makes "disabled" a lie and makes re-enabling fail on a port it still holds. - Disposal does not depend on the plugin behaving. The runtime empties the
disposable bag even when the plugin's
deactivate()throws or does not exist, in reverse registration order, isolating each disposer. - A failed
activateis still cleaned up, and the plugin's owndeactivate()is not called: it never finished starting, so it has no state to tear down. - Plugins never touch process signals or
process.exit. Core callsdeactivateon shutdown:gracefulShutdowninsrc/backend/starter.tscallsshutdownPlugins(), which stops every plugin in reverse activation order. A plugin that installs its ownSIGTERMhandler and exits skips the rest of that sequence, including other plugins and the database flush. - Enable, disable, enable must work without a restart.
States
enabled, disabled, blocked (a hard dependency is missing, recoverable),
failed (activation threw, a dependency cycle, or the error budget tripped).
lastError carries the reason.
Error budget
Core wraps plugin callbacks it invokes. Five failures inside 60 seconds (both
configurable) marks the plugin failed and deactivates it, because a plugin
throwing on every call is worse than one that is off.
POST /plugins/:id/retry clears the budget and starts it again.
What this protects, and what it does not
Be precise about this, because it is easy to overclaim and the previous architecture did.
What the capability system gives you
- Every privileged thing a plugin can do through
ctxis declared in a manifest you can read before installing it. - Every privileged call is checked against what was granted, and refused if it was not.
- Every privileged call, allowed or refused, leaves an audit line naming the plugin and the acting user. You can answer "what did this plugin do, and on whose behalf".
- A plugin cannot name a user and be believed, so it cannot act as someone it was not invoked by.
- Removing a capability from a manifest revokes the grant on the next boot.
What it does not give you
- Containment. A plugin runs in the server process. It can
import("node:fs")and read whatever the server user can read, open its own sockets, spawn processes, and reach core modules directly. Guardingctxdoes not change any of that. - Protection from malicious code. A plugin that wants to bypass the gate can. Nothing here stops it.
So the honest boundary is: capabilities stop accidental and casual overreach, and make every privileged call auditable. What stops malicious code is signing, review and the kill list - the same trust model as any other software you choose to install on a server.
The old worker tier claimed more than this, and the claim was not true either:
worker_threads shares the process, so a worker plugin could already reach
anything core could. The honest options for real containment are a child
process with --permission, or a WASM isolate. Neither is on the roadmap, and
until one is, this document does not pretend otherwise.
Legacy core imports: the debt D1 removes
The twelve bundled plugins predate the SDK. They still reach core by relative
path (../../../src/backend/...), which a build-time string rewrite in
scripts/copy-bundled-plugins.cjs patches to the compiled output path.
This is expected and temporary. The SDK does not yet expose what they need: the SSH connection pool, host resolution, the repository layer, the shared component library. D1 removes the debt once A3 through A8 have given them supported APIs to move onto.
What the lint fence enforces today:
- Core must not import a plugin backend. Error, no offenders.
- A plugin backend must not import frontend code or use the
@/alias. Error, no offenders. - The shell must not import plugin components. Warning with 21 offenders, which is exactly the work A7 does. It becomes an error then.
- Plugin backends importing core by relative path is not yet flagged, because there is nowhere for them to go.
Known specifics:
- TODO(B18):
plugins/ai/backend/tools/executor.tsimports../../../automations/backend/routes.jsdirectly. It is declared as a harddependenciesentry so the loader starts automations first and marksaiblocked if automations is disabled. In B18aicalls automations through anautomationsservice as anoptionalDependencyand the direct import goes away. This is the only cross-plugin import in the repo. - Eight plugins keep their original core URLs through the dispatchers in
src/backend/database/routes/*-dispatch.tsrather than/plugin-api. A4 moves them. - Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005, docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main server.
src/ui/shell/pluginLoader.tsstill hardcodes plugin ids inBUILT_IN_TABS_BY_PLUGINandapplyFirstPartyActions. A7 removes both.