Files
Termix/packages/plugin-sdk/ARCHITECTURE.md
T

206 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. Phase B converts one feature per step by following Converting a feature into a plugin.


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/db defineTable(), the column builders, the legacy-table map
@termix/plugin-sdk/ddl The per-dialect DDL emitter, shared with the CLI
@termix/plugin-sdk/table-builder buildTable(), a definition as a queryable Drizzle table
@termix/plugin-sdk/frontend The app object types, the hooks, invokeAction()
@termix/plugin-sdk/ui Core's shared components, a fixed public list (see UI)
@termix/plugin-sdk/manifest Manifest types, validation, the JSON schema
@termix/plugin-sdk/capabilities The capability catalog
@termix/plugin-sdk/settings Settings field types and the shared value validator
@termix/plugin-sdk/host-commands Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 Client a plugin got from ctx.ssh
@termix/plugin-sdk/ssh-certs applyCertificateAuth(): OpenSSH certificate auth on an ssh2 client, for plugins that issue short-lived certificates
@termix/plugin-sdk/testing Test helpers: createMockCtx(), createTestDb() and more

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 no exceptions:

  • eslint.config.mjs runs a local rule, termix-plugins/stay-inside, on every file under plugins/*/src and plugins/*/tests. It refuses @/... and any relative import that resolves outside plugins/<id>/, which covers core (src/) and every other plugin.
  • The same config refuses a src/ file importing plugins/*/src or plugins/*/dist.
  • scripts/check-core-plugin-ids.cjs (see UI) refuses core naming a plugin at all.

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:

  1. the manifest declares it, and
  2. 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.

A5 delivered this. A plugin declares short names in contributes.permissions, and core registers each as <pluginId>.<name>. The plugin never writes the prefix, so it cannot claim a core group or another plugin's namespace: parseManifest refuses a name starting with hosts, credentials, admin or the plugin's own id, and the runtime re-checks against every namespace already in the catalog, because the validator cannot know which other plugins exist.

The ids are unchanged by the move. ai + use is ai.use, which is exactly what the core catalog held before, so no role had to be migrated when ai.* and automations.* left PERMISSION_CATALOG and SYSTEM_ROLE_DEFAULTS.

A permission outlives its plugin. Core records every id it has ever registered in rbac_known_permissions, loaded at boot before any request is served and backfilled from what roles already hold. Disabling a plugin now only marks its group enabled: false: the group stays in the catalog so the role editor can grey it, and PUT /rbac/roles/:id keeps accepting it. Previously the group was unregistered outright, so an admin could not save any change to a role that still held one of these while the plugin was off. A permission nobody has ever registered is still rejected.

Defaults apply once. defaultRoles names the seeded system roles (admin, user) and nothing else. Each application is recorded in rbac_applied_defaults, a core table with no foreign key to plugins, so an admin who revokes a default does not get it handed back on the next boot or by reinstalling the plugin. That ledger used to live in plugin_storage, which cascades with the plugin row.

Both tables are deliberately unlinked from plugins: the whole point is surviving the plugin being gone.

ctx.rbac is the plugin-side view: has(permission) for the current actor, hasFor(userId, permission), and require(permission) as route middleware. All three resolve a bare short name against this plugin's prefix and take a full id belonging to a core group or another plugin as given, which is what makes a cross-plugin check expressible. require additionally refuses anything the plugin does not declare itself, at registration time, and denies with the same 401/403 bodies requirePermission() sends.

5. Data

Plugins own their tables. A plugin declares them with defineTable() from @termix/plugin-sdk/db and ships migrations for sqlite, postgres and mysql under migrations/<dialect>/NNNN_name.sql. Table names are prefixed p_<id with - as _>_, which defineTable adds. ctx.kv is for small key/value state, and ctx.files.dataDir() (B13, needs files:own) is a per-plugin folder, <DATA_DIR>/plugin-data/<id>, created on first call, for state too large for ctx.kv (session recordings, uploaded files); a plugin lays out its own subdirectories underneath it. It is kept apart from <DATA_DIR>/plugins/, where the loader looks for user plugins (D4). Core schema.ts ends up holding only core tables.

One table object, three DDL emitters. The sqlite-core definitions already encode correctly on every engine at query time, which is why core's own 44 repositories use schema.ts on all three. Only DDL genuinely differs, and that mapping lives in @termix/plugin-sdk/ddl so termix-plugin migrations and the server's runner cannot drift into disagreeing.

MySQL cannot index TEXT. Any column carrying a key becomes varchar(255), and defineTable refuses an index on text() or json() outright rather than silently truncating it.

Migrations are applied at activation, before activate() runs, so a plugin's first query never meets a missing table. Applied migrations are recorded in plugin_migrations with a checksum, which makes them immutable: editing one that already ran blocks that plugin rather than leaving two installs with quietly different schemas. A migration that throws marks its plugin failed and core keeps booting.

Adoption is how a feature keeps its rows when it moves out of core. The legacy table is renamed into the plugin's namespace (user_workspaces -> p_workspaces_workspaces) rather than copied, so nothing is duplicated and no row is migrated one at a time. LEGACY_TABLE_OWNERS maps each legacy table to the one plugin allowed to adopt it, enforced by adoptLegacyTable, by the CLI at build time and by the runner at activation, because a plugin installed from a tarball never ran the CLI.

A plugin marks a definition with adoptLegacyTable("user_workspaces", defineTable(...)) and termix-plugin migrations writes the adoption for all three dialects. A plain SQL file cannot ask whether the legacy table exists, so the migration creates it when it is missing (a fresh install), creates the definition's indexes on it with IF NOT EXISTS, and then renames it. Both paths end with the same table. Two rules follow:

  • Keep the legacy column and index names in the definition. The rename carries the old indexes across, and a matching name is what makes IF NOT EXISTS skip them instead of adding a second copy.
  • MySQL gets no index statements in an adoption, because it has no CREATE INDEX IF NOT EXISTS. That is safe for every legacy table core ever shipped to MySQL, since core's drizzle migrations already created its indexes.

A MySQL TEXT column cannot take a literal default, so the emitter writes one as an expression (DEFAULT ('{}')), the way it already did for CURRENT_TIMESTAMP.

Write through ctx.db.client(), then call ctx.db.persist(). On SQLite the database lives in memory and reaches its encrypted file only when something asks, which core's repositories do after every write. A plugin write that skips persist() can be lost on restart. It is a no-op on Postgres and MySQL, needs db:own, and is not audited, because it follows a client() call that was. ctx.db.dialect says which engine is running. The table object encodes the same on all three, but MySQL has no RETURNING, so portable code reads a new row back by a key it chose (a sync id) rather than relying on .returning().

Anything DDL-only in db/index.ts is SQLite-only. alert_rules, alert_rule_channels and alert_firings were created only there and never declared in schema.ts, so they never existed on Postgres or MySQL at all. Automations replaced them and A3 drops them. A core table needs all three artifacts in lockstep: the schema.ts declaration, the index.ts DDL, and the regenerated schema.pg.ts/schema.mysql.ts plus drizzle migrations.

What stays in core. After 2.9.0, schema.ts, the SQLite DDL in db/index.ts, performance-indexes.ts, the repository factory and the drizzle migrations hold only these 38 tables. Anything else is a plugin's, under p_<id>_.

Area Tables
Accounts users, sessions, trusted_devices, api_keys, user_external_identities, user_second_factors
Preferences settings, user_preferences, ui_preferences, host_sidebar_preferences, credential_sidebar_preferences, user_open_tabs
Hosts ssh_data, ssh_folders, recent_activity
Credentials ssh_credentials, ssh_credential_usage
Sharing and RBAC roles, user_roles, host_access, folder_access, credential_access, shared_host_secrets, shared_host_auth_overrides, shared_credential_secrets, rbac_known_permissions, rbac_applied_defaults
Audit audit_logs
Sync sync_records, sync_conflicts, sync_link
Plugin runtime plugins, plugin_permission_grants, plugin_registries, plugin_install_counts, plugin_storage, plugin_settings, plugin_migrations

A few core tables still carry columns a plugin reads through its own host or user settings after the upgrade (the moved ssh_data feature columns, the 2.8 OIDC columns on users, sessions.oidc_sub/oidc_sid). They are dropped in 3.0.0; see Moving a host column into a plugin.

Exports and imports follow the plugins. The per-user SQLite export and the JSON user export include every row the user owns in a plugin table: any table a running plugin declared with a refUser() column (src/backend/plugins/user-data.ts). Encrypted columns stay behind, since they are sealed with this server's keys. An import reads each table by its p_ name, or by the legacy name it adopted for a file written before 2.9.

Sync entities are registered, not hardcoded. ctx.sync.registerEntity adds an entity to sync between a linked desktop and its server; core registers its own the same way in src/backend/sync/entities.ts. The wire names are part of the protocol: records on both sides match on them, so never rename one.

How sync works. A desktop links to one server account (Sync in the rail) and keeps an offline copy of that account's data. Nothing records a change as it happens. On both sides a reconciler hashes every registered row the user owns and compares it with sync_records:

  • On a server, a new, changed or missing row bumps the record's revision and gives it the next seq. GET /sync/v2/changes?cursor= pages through records after a cursor, so a desktop never compares timestamps from two clocks.
  • On a desktop, a row whose hash differs from the one recorded at the last sync is a local edit to push. POST /sync/v2/push carries the revision the desktop last saw; if the server moved on, the op is a conflict, the server's version wins and the local one is kept in sync_conflicts for the user to restore.
  • Every op gets its own result, so one refused row never holds up the rest. A refused row is not sent again until it changes.
  • GET /sync/v2/events is a server-sent event stream that tells a linked desktop to pull now.

Because deletes are found by reconciliation, a plugin needs nothing beyond the registration: a registered row that is gone is a delete. There is no tombstone table any more.

What a registration says.

  • references translate local ids to syncIds on the way out and back. A field is a column (credentialId with syncField: "credentialSyncId") or a path into a JSON column (jumpHosts[].hostId, layout.panes[].hostId); idType: "string" keeps an id stored as a string. A syncId with no row on the receiving side keeps the value the column already has, and a row written before its target is written again once the target exists.
  • encryptedFields are decrypted with the owner's data key for the wire and encrypted again on arrival. A field that cannot be decrypted keeps the row out of the feed; it is never sent empty.
  • readOnlyFields stay on their device: never sent, never overwritten, not part of the hash.
  • shouldSync(row) leaves rows out in both directions, for state that belongs to one install, such as the workspaces plugin's "Last Session" row.
  • permissions (create, update, delete) are checked on the server before a pushed change is written.
  • serialize and deserialize run after references, for anything they cannot express. afterWrite runs after a synced row is written and remove replaces the plain delete, for rows with side effects of their own.
  • answersTo names a plugin entity by a name core uses without knowing the plugin: core's host quick actions point at commandSnippet, which the snippets plugin's snippets entity answers to.

The manifest lists the wire names under contributes.syncEntities, so a server can name them while the plugin is off and a desktop can list them in its sync settings.

Plugins on a linked desktop. The manifest's desktop field says how a linked desktop treats a plugin. mirror (the default) follows the server: a plugin installed there is downloaded from /sync/v2/plugins/<id>/package, verified like any .tmxplug, installed, and switched on or off with the server, with the capabilities an admin granted it there. local is decided on each desktop (serial ports), and server never runs on a linked desktop (ACME, sign-in methods, automations). While linked, the admin toggle for a mirrored plugin answers 409.

6. HTTP and WebSockets

Plugin routes live under /plugin-api/<id>/ and sockets under /plugin-ws/<id>/<path>, served by the main backend with core auth. No plugin gets its own port or nginx block.

A4 delivered this. A plugin calls ctx.http.router(options) and gets an Express Router core mounts at /plugin-api/<id>/, or ctx.ws.route(path, handler) for a socket. Core runs the same middleware in front of every plugin:

  1. Auth, unless the path is listed in options.public. A public path is audited when the router is registered and logged on every request, because "this plugin opened a hole in auth" should be findable later. D2 lists a running plugin's public HTTP paths and public socket paths as publicRoutes on GET /plugins (admins only), and audits a public socket when it is registered. It matches the full path, with :param segments allowed, so declaring /webhook/:token public cannot open /webhook/:token/anything. A trailing /* is the one deliberate prefix (C3): /chooser/:id/* opens that path and everything below it, for a page the plugin proxies whose assets it cannot list.
  2. The actor, from the user core authenticated, never from the request body.
  3. An enabled check returning 503 while the plugin is disabled. That is a different fact from 404 ("no such plugin") and a caller can act on it. A disabled plugin has no router left, so the dispatcher answers 503 for any installed id and 404 only for an unknown one; sockets do the same on the upgrade (D2).
  4. Body limits, defaulting to the 2mb core itself accepts. database.ts skips its own global parser for /plugin-api so a plugin's bodyLimit actually applies; rawBody turns the parsers off for a router that parses its own (multer, a raw body for signature checks).
  5. An error wrapper that logs against the plugin, never leaks a stack, and feeds the error budget. A 4xx (a body that does not parse, one over the limit) answers its own status and does not count, and nothing an unauthenticated request causes counts either, so five junk requests to a public route cannot switch a plugin off (D2). A public socket's guest runs with no actor at all, the same as a public HTTP route.

ctx.rbac.require(permission) adds a per-route RBAC gate, and a plugin may only require a permission it declares itself.

Both surfaces need network:serve. The declaration is checked when the router or route is created; the grant is checked per request and per upgrade, because it lives in the database and router() has to be synchronous. A revoked grant therefore takes effect on the next request without a restart: HTTP answers 403, an upgrade is refused 403.

Sockets are served from the main server's upgrade event, on both the HTTP server and the direct HTTPS one. Auth is the same utils/ws-auth.ts path every other Termix socket uses, and a token still awaiting TOTP is refused exactly as it is for HTTP. Binary frames and backpressure are untouched. Everything a plugin registered is disposed on deactivate, and live sockets are closed, not merely unrouted: a terminal session must not outlive the plugin that owns it.

ctx.ws.upgrade(path, handler) hands over the raw upgrade, after core has authenticated it, for a library that insists on owning its own WebSocketServer. guacamole-lite is why it exists. Three routes are public because they authenticate themselves in a way core cannot: the terminal (a share-link guest arrives with a share token), the Docker console (it refuses a socket without a signed-in user holding docker.use, and re-checks the data key per message), and the Guacamole display (a single-use encrypted connection token in the query, minted by an authenticated route).

B15 moved the Docker console off ctx.ws.upgrade and its own WebSocketServer onto ctx.ws.route with public and optionalAuth: core resolves the caller's token, the handler closes a socket without a user or without docker.use, and it checks isDataUnlocked() on every connect message. Nothing is left at module scope, so disable, enable while a console is open closes it and the next enable serves new ones.

B9 added optionalAuth for a public route: core still verifies a token when the client sent one and hands the handler that user, else an empty id. The terminal uses it, because one socket serves both signed-in users and share-link or room guests, who arrive with a share token instead. The route is public so a guest can reach it at all; guest auth itself stays in the plugin's handler (noted for D2). Every route connection now also carries clientIp, requestOrigin and isDataUnlocked(), the data-key check a long-lived socket repeats per message so an expired session stops being served.

Old URLs outside the namespace. A few 2.8 URLs are called by something outside Termix: identity provider callbacks, the Vault OIDC callback, the Termix ID resolver, back-channel logout. A plugin lists them in contributes.http.legacyRedirects as { from, to, status? }. Core redirects a request at from (or under it) to /plugin-api/<id><to> with the rest of the path and the query kept, 307 by default or 308 for a permanent move. from may be any absolute path outside the plugin framework's own prefixes, and core routes win a clash. It is a redirect rather than a forward because a cookie the plugin set is scoped to its own path. contributes.http.legacyPaths is the other form, for URLs already under /<plugin id>/ (automation webhooks), which the plugin's router serves in place. 2.8 clients' login routes (/users/<method>/authorize, /users/<method>/login) stay in core as generic aliases of the login pipeline.

The nginx configs keep one small group of blocks for those public 2.8 URLs (/vault/oidc/callback, /termix-id/u/, /automations/webhook/); the rest already fall under core's /users and /host blocks. A new plugin never needs an nginx change: everything it serves is under /plugin-api/, /plugin-ws/ or /plugin-assets/.

Socket and ssh2 event listeners fire outside the upgrade's async context, so a plugin that calls privileged ctx members from them binds them with Node's AsyncResource.bind while the actor is still set; the terminal does this for every listener it registers.

7. Settings

Plugins declare settings fields in the manifest with scope admin, user or host. Core renders them from the schema, stores values in plugin_settings, and encrypts secret fields.

A6 delivered this. A field is { key, type, labelKey, ... }, with types boolean, string, number, select, multiselect, secret, textarea, json and custom. requires names a boolean in the same scope that gates it, group is a section heading, and permission is who may write it: admin fields fall back to admin.plugins.manage, and a short name resolves against the plugin's own permissions exactly as ctx.rbac does.

hidden: true (B14) keeps a field out of the generic form: it is stored, validated and encrypted like any other, but the plugin edits it in its own UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC and Telnet host editor tabs.

Where fields render. In 2.9.0 the plugin system is invisible to users: there is no plugin list, enable switch or capability dialog, and nothing in the UI says "plugin". That UI arrives with the store in 3.0.0. Settings show up as features instead:

  • user fields render as an accordion section named after the plugin in the User Profile panel, and admin fields do the same in the Admin Settings panel (src/ui/settings/FeatureSettingsSections.tsx). group becomes a card inside that section.
  • host fields get a host editor tab of their own, named after the plugin (src/ui/settings/host-feature-tabs.tsx). editorGroup ("top" or "ssh", default "top") and editorOrder place it among the other tabs. A plugin that registers its own section with registerHostEditorSection gets no generated tab, so it must draw any non-hidden host field itself.

type: "custom" names a component the frontend registered, for the few things a schema cannot express (a device browser, a provider list). It is deliberately narrow: a plugin supplies data everywhere else, so it cannot ship its own form styling and drift from the rest of the app, and a page disappears cleanly when its plugin does.

One service, two callers. src/backend/plugins/settings.ts is behind both ctx.settings and the HTTP routes, so validation, encryption and redaction are decided once. Two implementations would drift, and the one that drifted would be the one writing to the database.

The manifest is the schema. A read is driven by the declared fields, not by the stored rows: an unwritten field comes back with its default, and a row for a field the plugin has since dropped is ignored rather than handed to the UI. A write to a key the manifest never declared is refused, which is what stops a PUT from filling a plugin's namespace with arbitrary keys.

Secrets never leave the server. A secret field is encrypted with encryptSystemSecret (the installation key, not a user DEK: these have to be readable before any user is unlocked) and every HTTP read replaces it with { set: boolean }. Sending that marker back is a no-op, so saving a form cannot clear a key it was never shown. Clearing one writes null rather than an encrypted empty string, so it reads back as unset.

Storage. plugin_settings(plugin_id, scope, scope_id, key, value, encrypted, updated_at), unique on the first four. scope_id is null for admin, a user id for user and a host id as text for host. It is polymorphic, so it carries no foreign key and the engine cannot cascade it: UserRepository and HostRepository delete these rows explicitly, with tests that fail if those calls ever go. The foreign key to plugins is real, so uninstalling leaves nothing.

Routes, all with core auth: GET|PUT /plugins/:id/settings/admin (admin.plugins.manage, or the field's own permission), /settings/user (always the caller, never a user id from the body) and /settings/host/:hostId (needs edit access to that host). A rejected PUT returns 400 { errors: { <key>: message } } per field, so a form shows every problem at once.

Host payloads carry them. Host list and get responses include a pluginSettings map, enabled plugins only and secrets redacted, built in one query for the whole list rather than one request per plugin per host. It is attached after the sanitizers, because the connect-level projection of a shared host reduces it to an allowlist.

ctx.settings is get/set, getUser/setUser, getHost/setHost, getAll, onChange (disposed with the plugin), onValidate, listHostValues and readCore. Reading and writing a plugin's own settings needs no capability: the manifest already declares every field, and a plugin that had to ask permission to read its own configuration would be useless. Only readCore, which reaches outside the plugin's namespace, is gated on settings:read-core, and it serves a short allowlist (CORE_SETTINGS_ALLOWLIST) rather than the whole settings table.

D0 added three pieces:

  • onValidate(scope, validator) checks a save from the profile or admin panel or the host editor before anything is written. The validator gets every value in the save (and the host id for host scope) and returns field key to message for what is wrong. ssh-terminal checks that its image paths are absolute, step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
  • listHostValues(key) lists every host that saved a value for one of the plugin's host fields, across all users, with the host's owner, for a background job that must know which hosts to act on and as whom before any request exists (proxmox's auto-sync). It needs hosts:read and refuses a secret field.
  • Host fields take enableDefault (on the host section), defaultFrom, shareRead and ownerOnly; see the manifest reference. A host settings save also changes the host's sync hash, so sync carries it.

8. UI

A plugin frontend is a built ESM bundle, dist/frontend.js, exporting activate(app) and optionally deactivate(). The shell has no plugin ids and no imports from plugins/; everything a plugin shows goes through the app object. scripts/check-core-plugin-ids.cjs (run by lint) scans all of src/ and fails on any plugin id core spells: a bare literal ("docker"), a plugin route ("/plugin-api/docker/..."), or an action, slot or permission id that starts with one ("docker.open"). The shell (src/ui) also may not spell a view a plugin owns (a tab, panel or dashboard card id). There is no allowlist. Three things are exempt, by rule rather than by entry:

  • tests and locales;
  • src/backend/upgrade/, the one-time 2.8 to 2.9 data moves, which have to name the plugin each piece of data moves to, the same way LEGACY_TABLE_OWNERS names the plugin that adopts each legacy table;
  • "totp" inside src/backend/hosts/connect/, where it is the SSH keyboard-interactive one-time-code prompt kind, not the login plugin.

Where core needs something from whichever plugin provides it, core names the contract and a plugin fills it: a slot (dashboard.counters, admin.userTabs), an action (host.openFiles, snippet.list, snippet.resolveForTerminal) or a component id (credentials.secretHint, credentials.secretManager). With no provider, the slot renders nothing and the action answers undefined.

The loader

src/ui/plugin-host/loader.ts runs after login and again whenever plugin state may have changed: the termix:plugins-changed window event (the admin toggle fires it) and window focus, throttled to 30 seconds. There is no push channel yet. Each pass:

  1. Fetches GET /plugins (frontend, css, assetVersion, locales, dependencies and contributes per plugin).
  2. Loads every plugin's locales into the i18next namespace <id>, enabled or not, because the role editor and the plugin list need disabled plugins' titles.
  3. Orders enabled plugins by dependencies. A plugin whose hard dependency is missing or off is skipped.
  4. For each plugin with a frontend: injects frontend.css, imports /plugin-assets/<id>/frontend.js?v=<assetVersion> and calls activate(app). In dev the workspace plugins are imported from plugins/<id>/src/frontend/index.tsx through Vite, so HMR works.
  5. Deactivates plugins that went away: runs deactivate() then every disposer. No reload.

A plugin that throws while importing or activating is marked failed, its partial registrations are disposed, and the rest of the app carries on. The same assetVersion is not retried until it changes.

Anonymous shared-session and collab pages have no session. They load only plugins whose manifest sets contributes.guest: true, from the public GET /plugins/public, and app.guest is true there.

B12 made those pages generic. A guest plugin lists the ?view= names it serves anonymously in contributes.guestViews (which needs guest: true), and /plugins/public returns them. src/main.tsx asks for that list when a URL carries ?view=: a guest view starts the guest runtime and renders the tab type whose standalone/standaloneViews claims it, anything else goes through the signed-in full-screen gate as before. The shell no longer knows ?view=shared or ?view=collab-guest; session-sharing declares both.

The login screen runs a smaller pass before anyone signs in: it activates only enabled plugins that contribute loginMethods or secondFactors, listed by the public GET /plugins/public-manifest. They stay active after login, and the full pass does not load the same bundle twice. See Auth below.

Shared modules and the import map

A bundle keeps react, react-dom, react-dom/client, react/jsx-runtime, i18next, react-i18next, sonner, @termix/plugin-sdk/frontend and @termix/plugin-sdk/ui as bare imports. Core's Vite build (scripts/vite-plugin-termix-plugins.mjs) emits one entry per shared module, writes a stable unhashed shim for each at dist/shared/<name>.js, and injects an import map into index.html mapping the bare names to those shims. In dev the map points at Vite's virtual modules instead. The map is inline, so both nginx configs allow it by hash in script-src; scripts/check-importmap-csp.cjs (run by lint) fails when the hash is out of date and --write updates it. The map changes only when the list of shared modules does.

Electron loads index.html from file://. The map uses relative URLs, and plugin bundles come from the embedded backend (http://localhost:30001/plugin-assets/...), which is why /plugin-assets sends Access-Control-Allow-Origin: *.

Serving bundles

GET /plugin-assets/<id>/<path> serves the directory holding a plugin's frontend bundle and its locales/ directory, nothing else: never backend.js, never a path outside those two, only .js, .css, .json and .map. It is unauthenticated like /assets, since import() cannot send the bearer header and bundles are not secret. With ?v= the response is cached as immutable; without it, no-cache. nginx has a ^~ /plugin-assets/ block.

The app object

Every register* call returns a disposer and is also disposed automatically when the plugin deactivates. Every registered component is wrapped in the plugin's scope (so the hooks know which plugin they belong to), an error boundary and Suspense.

Member What it does
registerRailItem A rail button, hidden from users without its permission. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view. placement: "footer" puts it above the profile, and useBadge is a hook whose number shows on the icon (E1)
registerPanel A rail panel, id in contributes.panels or contributes.tabs
registerTab A tab type, id in contributes.tabs. Options cover persistence, layouts, singletons and host needs
registerHostEditorSection A host editor tab in the top strip or the SSH group, with form, setField and updateForm
registerHostAction A connect or open action on a host: sidebar row, palette, dashboard, default connect
registerHostProtocol A connection protocol next to SSH (B14): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port
registerHostBadge, registerHostContextMenuItem Host row badges and context menu entries
registerPaletteEntry A global or per-host command palette entry
registerDashboardCard A dashboard card, id in contributes.dashboardCards
registerHomepageWidget A homepage widget, with an optional edit form
registerSettingsComponent A component a type: "custom" settings field names
registerAction, declareActionSlot Frontend actions and the slots a plugin owns
registerSlotContribution, invokeAction Fill a slot with a button or a component, with an optional when; call an action and get its result
registerSshAuthEditor An SSH auth method's editor in the host editor
registerLoginMethod, registerSecondFactorUI Login screen UI for a method (placement: "inline" beside the password form) and a second factor challenge, each with an optional enrollment
api, wsUrl(path) axios on /plugin-api/<id>/ and the plugin's WebSocket URL
apiFor(origin) The same client for a resolved connection origin: "remote" reaches the desktop app's connected server (B14)
fetch(path, init) A raw fetch on /plugin-api/<id>/<path> with core's auth, for a streamed response (SSE) axios cannot read (B18)
t, hasPermission The plugin's strings and a permission check, for code outside a component (a toast from activate)
tabs.open, getLayout, applyLayout, onChange Tab control, used by workspaces; tabs.openRailView (B12) opens a rail view
guest, info, onDispose Guest mode flag, plugin info, extra cleanup
desktop available, remoteServerUrl() and onRemoteServerChange: whether the frontend runs in the desktop app and which server it is linked to (C6)
registerComponent A component other plugins and core render by id with PluginComponent
onSettingsChanged Called when one of the plugin's settings changes

A few contribution fields core reads generically:

Field Effect
RailItemContribution.simplePreset The item stays visible in the Simple interface preset, which hides every other plugin rail item
HostActionContribution.quickConnect The action is offered as a button in Quick Connect, opening its tabType for an address that is never saved
PluginSshAuthProvider.quickConnect (backend) The auth type works for an unsaved host, so Quick Connect lists it and renders the plugin's SshAuthEditor for its fields
SshAuthTypeInfo.quickConnect What useSshAuthTypes() reports for the above

Core calls outside a component (@termix/plugin-sdk/frontend): logActivity, getHostPassword, patchOpenTab, getCustomKeybindings, setHostAutoTmux, getClientPreference, listHosts (every host the user can see, fetched now) and listCredentials (the user's credentials without secrets, for a picker).

Hooks: useTranslation (the plugin's namespace), usePermission (a short name resolves to <id>.<name>), useSettings, useHost, useHosts, useCurrentUser, useTheme, useToast, usePluginApi, useTabs, useSshAuthTypes (every SSH auth type the server knows, with its plugin), useSlotContributions (the visible contributions to a slot another plugin owns, with their metadata, for a slot owner that builds a catalog from what was contributed rather than just rendering it in place - host-metrics uses it to list manager cards plugins added to host-metrics.managers next to its own). The SDK has no runtime dependencies: the hooks delegate to a host bridge core installs.

Slots and actions core names for plugins to fill: dashboard.counters (component slot in the dashboard's counters card; tunnels shows its active tunnel count there), admin.userTabs (component slot, one tab per contribution in the admin "manage user" panel, rendered with { user } and replaced by core's locked notice while the user's data is locked; snippets adds its tab), the host.openFiles action (the tab bar's file manager button), the snippet.list and snippet.resolveForTerminal actions (core's snippet pickers and the command palette's run flow) and the credentials.secretHint / credentials.secretManager components (the credential and host editors' secret reference UI). D1 added these in place of tunnels.statuses, tunnels.open, files.openHost, snippets.resolveForTerminal and the secret-sources.* component ids, which core used to call by the plugin's name.

Other slots: terminal.toolbar, terminal.toolbarStatus, terminal.sidePanel, terminal.overlay (declared by ssh-terminal; B9 renamed terminal.dock to terminal.sidePanel and added the status slot, where host-metrics puts its CPU, memory and disk bars; C3 gave overlay components send(type, data) for the session socket and connectPayload(), the data an <interaction>_auth_completed needs to reconnect, so a sign-in flow such as OPKSSH's lives entirely in its plugin), onboarding.steps, onboarding.features, onboarding.workflow, hosts.importMenu, hosts.panel, proxmox.hostEditor, shell.overlay, dashboard.hostMetrics, homepage.hostMetrics, credentials.badges and dashboard.secondaryView. C6 added credentials.badges, a component slot after the key/password badge on each saved credential row ({ credentialId }); termix-identity shows its "ID" badge there, so the credential list no longer fetches Termix ID data. B16 added the middle two, component slots in the dashboard's host status card ({ hostId, online }, one per host row) and the homepage's host status widget ({ hostId, shownMetrics, online }): core draws the status and a plugin draws the numbers, so core no longer calls the metrics API. B19 added dashboard.secondaryView (component slot, one contribution rendered at a time, titleKey names its tab): the dashboard's "Dashboard / " toggle renders whatever is contributed instead of knowing about the homepage plugin by name, so the toggle disappears with no contributor and would show a second plugin's view the same way if one ever claimed it too. B12 added shell.overlay, a component slot the shell renders once at its root for always-mounted plugin UI: session-sharing keeps its share dialog and its room-invite watcher there. session.remoteDisplay moved to session-sharing, which declares it and draws whatever remote desktop contributes to it in rooms and share links. remote-desktop.toolbar (button slot, B12) is remote desktop's own, invoked with { hostId, sessionId, protocol, tabInstanceId }. host-metrics.managers (component slot, B3) is host-metrics's own: tailscale contributes its manager card there instead of host-metrics knowing tailscale exists, and host-metrics works with or without tailscale enabled. Cross-plugin frontend calls go through actions: automations.list, fleets.list, session.remoteDisplay.token. B16 added host-metrics.disk (the file manager's disk usage bar) and snippets.get / snippets.execute (host-metrics's quick actions); each answers undefined while its plugin is off.

B9 moved the terminal itself into ssh-terminal, which adds terminal.open(host, { path?, joinSharedSessionId?, joinShareId?, label? }) (the file manager's "open terminal here", the connections panel's "join a shared session") and replaces ShellApi.openTerminalTab, which the shell no longer has.

ShellApi.openHostEditor(draft?) opens the host editor for a new host with a HostDraft (name, ip, port, username, authType) filled in, which the user reviews and saves (tailscale's "Add host" on a device). saveQuickConnect saves only what core's own quick connect payload carries, so the terminal offers it only on a host with quickConnectSavable, which core sets for the password, key, credential, none and agent auth types.

Components by id. app.registerComponent(id, component) offers a component to other plugins and core, which render it with PluginComponent from @termix/plugin-sdk/ui (a fallback while the owner is off) or usePluginComponent(id). ssh-terminal registers terminal.view, the embeddable terminal the collab room, the homepage SSH widget, the tmux monitor and the file manager's window render instead of importing it; its handle comes back through a handleRef prop, since a registered component cannot take a React ref.

Session tabs. TabOptions gained commandTarget (history, macros and SSH tools act on the last one focused; PanelProps.targetTab hands a panel that tab), ownBackground (the shell leaves the frame transparent) and multiInstance (each open is a new tab). TabHandle documents what a session tab puts on handleRef: focus, reconnect, disconnect and, for sharing, getShareTarget() for sharing. These replaced every check of the terminal and local-terminal tab types in the shell.

B2 added a live-terminal-session surface, for a plugin that needs to push resolved text into an open SSH session rather than just fill a slot with a button: ssh-terminal registers terminal.listSessions() (open sessions with their host info), terminal.sendToActive(text, { run }) and terminal.sendToSession(sessionId, text, { run }), backed by a small registry its terminal tab wrapper populates from the core terminal ref it already holds. The snippets plugin's run/paste flow is the first caller.

Host protocols (B14). app.registerHostProtocol({ id, settingKey, portKey, defaultPort, titleKey, descriptionKey, icon, quickConnect }) adds a protocol next to SSH. Its switch and port are the plugin's own host settings, so core reads them from host.pluginSettings[<plugin>] and never knows the protocol by name: the General tab draws the switch and saves it into the plugin's settings, the Hosts panel filters and groups by it, HostItem counts it, Quick Connect offers it (with a domain field when asked), and a host with SSH off takes the first protocol switched on as its connectionType and its port. hostProtocols(host) from @termix/plugin-sdk/frontend lists what a host has on ("ssh" included), for a plugin such as session sharing that offers every protocol without knowing who owns it. Remote desktop registers rdp, vnc and telnet.

View ownership

Who owns a tab, panel or card comes from manifests, so it is known even for a disabled plugin whose code never loaded. A saved tab or dashboard card whose plugin is disabled, failed, still loading or not installed renders "This needs the plugin" (PluginViewPlaceholder) and is never dropped from the saved layout. Tab restore waits for the first plugin pass, so a restored tab does not race its plugin.

The same holds when a saved layout is applied (a workspace): a tab type no running plugin has registered is reopened as that placeholder rather than skipped, and it stays in the next snapshot (isUnregisteredPluginTabType in src/ui/shell/shell-layout.ts). Only a tab whose host was deleted is skipped.

The ui entry

@termix/plugin-sdk/ui is public API, implemented by src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change; removing from it is a breaking one. Today it exports: alert, alert-dialog, badge, button, card, checkbox, dialog, dropdown-menu, input, label, password-input, popover, scroll-area, select, select2, separator, skeleton, switch, textarea, tooltip, sheet, section-card, metric-card, charts, the card grid, ConnectionScreen and the connection status helpers, SnippetVariablesDialog, FullScreenAppWrapper, the connection log context, TOTPDialog (its MFAPromptMode type lives in @termix/plugin-sdk/frontend since C1, so the shell spells no "totp"), SSHAuthDialog, BrowserSignInDialog, PassphraseDialog, HostKeyVerificationDialog, useTabs/useTabsSafe, ActionSlot, ComponentSlot, PluginComponent and FOLDER_COLORS (the colour swatches folders and workspaces pick from). B10 added popover, scroll-area and skeleton for the tmux-monitor plugin's session tree and its new-session/kill popovers. C3 removed OPKSSHDialog (the opkssh plugin draws its own) and replaced WarpgateDialog with BrowserSignInDialog (label, url, code), the one dialog every transport shows for a keyboard-interactive browser round.

B9 added what a terminal-like surface needs from the shell: cn, useConfirmation, useIsMobile, runAdaptivePolling; the terminal look (TERMINAL_THEMES, TERMINAL_FONTS, DEFAULT_TERMINAL_CONFIG, resolveTermixThemeColors, ensureTerminalFontsLoaded, the font zoom limits, the clipboard helpers and useAppTheme, the full theme id rather than light or dark), shared by the SSH and local terminals, the docker console and serial from src/ui/lib/terminal-look/; keyboard handling shared with the shell (findMatchingKeybinding, globalShortcutHandler, isTabJumpHotkey); the connection helpers (isElectron, resolveConnectionOrigin, pluginWsUrl, hydrateLocalSharedHostAuth, useConnectionDefaults). D0 moved the core calls a plugin makes for the signed-in user onto the typed host bridge, as @termix/plugin-sdk/frontend functions: logActivity, getHostPassword, patchOpenTab, getCustomKeybindings, setHostAutoTmux and getClientPreference. D1 added what the last plugins reaching into core used: Kbd, KbdKey and KbdSeparator; ConnectionLogPanel and the LogEntry type; createFrontendLogger(name), a console logger in core's format; the adaptive resource budget (getAdaptiveResourceBudget, markAdaptiveResourceUsed, runAdaptiveBackgroundTask) so a plugin's background work shares one budget with the shell; getDeviceId; createQuickConnectHost and isQuickConnectHost; and the desktop app's local file bridge types (LocalFileEntry, LocalDirectoryListing, LocalWalkResult, LocalTransferProgress and the rest), for a plugin that drives window.electronAPI.localFs itself. B16 added LineChart, useAdaptivePolling, useAreaPreferences (the Appearance density and chart options), and the homepage widget pieces WidgetTitle and runVisibleInterval (moved to src/ui/lib/ in B19, once a plugin rather than a core feature became their biggest caller), for host-metrics's cards and its metrics chart widget.

D0 gave plugins their own Appearance area. A manifest's contributes.uiPresets names a value per interface preset (simple, balanced, advanced), and usePluginUiPreferences() returns the one for the user's level with their changes on top, plus a set that saves a change. Changes are stored in the UI preferences under plugin:<id> and listed, with the plugin's name, where Appearance lets a user revert them. Docker (list or detail view, card or table layout) and host-metrics (column count) use it; version 2 of the UI preferences moved their 2.8 docker and hostMetrics overrides across. The terminal toolbar density and the file manager's view mode are still preset-seeded localStorage keys core writes, as in 2.8.

D0 also added:

  • usePluginApiFor(origin): the plugin's client for a resolved connection origin, as app.apiFor but usable in a component. The terminal toolbar's metrics bars use it for a host on the desktop app's remote server, and tunnels merges the remote server's statuses through app.apiFor("remote").
  • app.onSettingsChanged(listener): fires after the profile panel, admin panel or host editor saved one of the plugin's settings, instead of polling on focus.
  • A tab.menu slot: entries in a tab's right-click menu, invoked with the tab's surface handle; when sees { tab, handle }. Session sharing adds its share entry there, for a terminal whose toolbar is hidden.
  • The terminal toolbar lists every plugin's open host actions for its host (tmux monitor, docker, tunnels, host metrics) as quick links, so no plugin has to contribute a toolbar button to be reachable from a terminal. The frontend SDK gained useHostStatus(hostId): core's status for a host, from the shell's own polling, or null outside the shell. B14 added buildOriginWsUrl, getBasePath, resolveRemoteHostId and the ConnectionStage type, for remote desktop's display socket and its remote host lookup. B17 added a channel dialog and API for core's notification channels, which E1 removed again when channels moved to the alerts plugin: a plugin that wants its user to manage channels invokes alerts.openChannels. useConnectionDefaults now carries terminal defaults only; the RDP half became remote desktop's user settings. Publishing its .d.ts for plugins outside this repo is a follow-up for the repo split. B19 added PluginViewPlaceholder, for the homepage plugin's widget shell to render in place of a widget type whose owning plugin (docker, tunnels, host-metrics, file-manager) is off, and moved WidgetTitle and runVisibleInterval here from the deleted homepage feature directory (above), unchanged for their existing callers.

Strings

A plugin's strings live in plugins/<id>/locales/en.json and load into the <id> namespace, with core's translation as the fallback, so a plugin can still use common.*. Manifest keys (titleKey, labelKey) resolve in the plugin namespace; pluginKey(id, key) in core returns <id>:<key>. Translations go to plugins/<id>/locales/translated/<xx_YY>.json, which is where Crowdin writes them. Only en.json is edited by hand.

Type checking

npm run type-check covers every plugin through two root configs: tsconfig.plugins.frontend.json (plugin frontends and their tests, bundler resolution; it extends the app config because @termix/plugin-sdk/ui is core's own sdk-ui.ts) and tsconfig.plugins.backend.json (plugin backends, nodenext, the SDK's built .d.ts). Neither gives a plugin a path into core; the @/ alias the frontend config inherits exists for sdk-ui.ts itself, and lint refuses it in plugin code.

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.

A service call runs as the user its permission was checked for, so the provider reads that user from ctx.currentActor() and takes no user id from the caller. The workspaces plugin provides workspaces.saved (list()), which the AI assistant's list_workspaces tool reaches through an optional entry in its requires, answering unavailable: true while workspaces is off.

B7's tunnels.access is the first service with a hard consumer: web-endpoint lists tunnels in dependencies and a non-optional requires, because its tunnel endpoints cannot work without it, while automations keeps it optional for its one tunnel step. forward() resolves only once the forward is listening and the target answered a probe, so a caller never has to poll the plugin's state. A plugin can also listen for another plugin's plugin.<id>.* events without any capability: automations turns plugin.tunnels.tunnel_disconnected into its tunnel_disconnected trigger.

B8 added a second, narrower use of ctx.registry: a core route that touches every plugin's host-scope settings generically (without importing any one of them) consumes ctx.registry.provide("<pluginId>.hostImportNormalizer", fn). host-bulk-routes.ts's Termix-JSON import path is the first caller, through applyPluginHostImportSettings in host-plugin-settings.ts: for every enabled plugin that declares contributes.settings.host, it looks up "<id>.hostImportNormalizer" and, if the plugin registered one, calls it with the raw imported row and writes back whatever it returns (or nothing, for null). This is not part of the SDK's typed ctx surface - it is a plain ctx.registry convention, just with a name core's own code knows to look for. web-endpoint (src/backend/host-import.ts) is the first plugin to register one; remote desktop's (B14) also reads a Termix export's pluginSettings and the flat fields hosts had before 2.9.0. Host exports carry every plugin's host settings (secrets redacted) under pluginSettings, which is what makes the round trip work without core naming a plugin.

D0 finished the generic host settings paths, all keyed off manifests:

  • Import writes an export's pluginSettings (non-secret declared fields, validated like any save) before the plugin's normalizer runs. The raw SQLite export carries host-scope plugin_settings rows in its own table, and its import reads them back, plus the 2.8 columns through the normalizers.
  • The Hosts panel's feature filter and the bulk enable and disable menu list every plugin whose host section has an enableKey; the bulk route takes pluginEnable: { [pluginId]: boolean }.
  • Remote sync carries a host's non-secret plugin values. A value that is a local row id (vault's profileId) is translated with ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
  • ctx.registry.provide("<id>.hostPayloadLegacy", fn) puts fields back on the host payload in their 2.8 shape for clients outside Termix. host-metrics uses it for the statsConfig Termix-Mobile reads; remove it once the mobile app reads pluginSettings and GET /host/status.
  • A host field's shareRead hides it from shared recipients below that level, and ownerOnly keeps a shared editor from changing it. Remote desktop's guacamoleConfig (which can hold a gateway password) is shareRead: "edit", vault's profileId is ownerOnly.
  • The bulk import accepts any auth type core or a plugin registers or declares in contributes.auth.sshAuthTypes.

B9's terminal provides sessions.live (live SSH sessions: look up, end, remove a share's participants, hand a room share's control over, subscribe to output, write) and terminal.history (the actor's command history, which the AI assistant reads). It consumes three optional services that later steps provide: tmux.sessions (B10; without it the terminal skips tmux attach), sessions.sharing (B12; member joins and collab room events) and recordings.writer (B13; without it nothing is recorded). The terminal keeps the asciicast formatting and the 300 ms batched flush (issue #1049) and hands the provider one append per batch. Service names need a dot, so "tmux" and "recordings" became tmux.sessions and recordings.writer.

B13 is the session-recording plugin, which provides recordings.writer. It adopts session_recordings, keeping userId a plain column rather than refUser() (a recording is evidence about the host as much as the person, so it outlives the account) and clearing it on the user.deleted event instead of cascading. open(meta) returns null when the host's enableSessionRecording setting is off; otherwise a RecordingSink with append(chunk) (one call per already-batched chunk, so it never writes per-chunk itself), persist(summary) and discard() for a session that recorded nothing. createFinished(input) is the second half of the contract, for a caller that already wrote its own recording to disk and only needs the database row: remote-desktop's guacd recordings use it instead of importing the old repository directly. B14 added enabledFor(hostId) (version 1.1.0), because guacd has to be told to record before the session exists: remote desktop records an RDP or Telnet session only when the plugin is running and the host's switch is on, and writes the row as the session's user. Recording files live under ctx.files.dataDir()/session_logs/<user>/<session>.cast. D4: createFinished moves a file written elsewhere (guacd's, in remote desktop's recordings folder) to ctx.files.dataDir()/session_recordings/<folder>/, since playback and retention only read inside the data folder. Retention (retentionDays, an admin setting) runs a sweep at boot and every 24 hours.

B15's docker plugin provides docker.containers (listContainers, action) and docker.events (subscribe(hostId, listener)), both gated on docker.use. The service name needs a dot, so the plain docker the step was sketched with became docker.containers. docker.events polls each subscribed host about once a minute as the subscribing user and diffs the snapshots; a subscription is idempotent, which is how automations picks it back up after docker is disabled and enabled again (it re-subscribes every watched host each minute). Automations uses both as optional services: its Docker step goes through action, and its docker_event trigger through subscribe.

A service call runs a permission check for its actor, so a share-link guest, who has no user, cannot make one. Guest link resolution is therefore published on ctx.registry as sessions.sharing.guests instead: the token is the authority there.

Core consuming a plugin service. The open tabs route is still core and lists the caller's own terminal sessions through getServiceImplementation(service, range, name?) in service-registry.ts (hosts/live-terminal-sessions.ts wraps it). Core already authorized the request, so this skips the per-call check and returns the same object plugins get, or undefined while no compatible provider runs.

Named providers (B12). A service several plugins provide side by side is keyed by a provider name. The manifest lists the names in provides[].names, ctx.services.provide(service, impl, { name }) refuses any other, ctx.services.get(service, { provider }) reaches one, and ctx.services.providers(service) lists the running ones. A requirement is satisfied by any compatible provider. sessions.live is the first: keyed by session type, ssh-terminal provides ssh and remote desktop provides rdp, vnc and telnet (B14 delivered them), with createViewerToken(sessionId, readOnly) for a viewer. A remote desktop session id is guacd's own connection id, and ownerEndSession closes it and every viewer joined to it. A consumer with no actor, such as a public guest route, calls it through ctx.asUser(<share owner>).

Session sharing (B12) is the session-sharing plugin. It provides sessions.sharing (member joins, room events) and sessions.sharing.guests, adopts session_shares, session_share_participants, collab_rooms and collab_room_members, and reaches sessions only through sessions.live. Its two public routes, /resolve/:linkToken and /guest/:token, check the token themselves and are rate limited per IP. The share button is a contribution to terminal.toolbar and remote-desktop.toolbar, and (D0) to the tab bar's tab.menu slot, which reads the terminal handle's getShareTarget. The shareable, canShare and openShareModal members of TabOptions are gone. Core's active connections list gets the sessions shared with a user from the sessions.sharedWithMe action.

Automations (B17) imports nothing from core. It reaches snippets (snippets.access, a hard dependency), fleets (fleets.access), tunnels (tunnels.access), Docker (docker.containers, docker.events), host metrics (host-metrics.viewers) and wake-on-lan (wake-on-lan.send v1, wake(hostId), which B20 provides) as services, alerts through ctx.notify (a notify step goes to the owner's inbox plus the channels it names, and a failed run sends the owner an automations.run_failed alert, E1), the HTTP step through ctx.fetch, hosts through ctx.hosts and ctx.ssh.resolveHost, and commands through ctx.ssh.withConnection. Every run executes inside ctx.asUser(<owner>), so each of those calls is the owner's. The scheduler tick is ctx.schedule.every, and triggers listen through ctx.events to host.status, host.login, internal.event, plugin.host-metrics.snapshot, plugin.host-metrics.health-check, plugin.tunnels.tunnel_disconnected and its own plugin.automations.automation_failed, which replaced the core automation_failed internal event.

A definition's trigger and steps say which of those optional plugins it needs (requirements.ts). An automation whose provider is not running is skipped: the run is recorded as skipped with "Needs the plugin", the reason is logged, and the engine carries on with the next one. The editor asks GET /editor-options for the running providers and only offers the triggers and steps they allow, and the list marks such an automation "Needs ". It provides automations.access v1 (list, get, create, run, gated on automations.view, with create and run also checking their own permissions) for the AI assistant, which lists automations as an optional dependency. It adopts automations and the five automation_* tables; the migrations keep the legacy links between them by hand. Its channel links lost their foreign key to notification_channels in its 0002 migration (E1), since channel ids now point into the alerts plugin's table. Core's hosts/automation-events.ts became hosts/internal-events.ts (emitInternalEvent), since it never was specific to automations. Webhooks live at /plugin-api/automations/webhook/<token>; the 2.8 path /automations/webhook/<token> is rewritten to it in both nginx configs, because external systems hold that URL.

AI assistant (B18) imports nothing from core. It adopts ai_providers, ai_conversations, ai_messages and ai_proposals (the migrations keep the conversation foreign keys by hand), keeps provider keys in ctx.secrets, reaches providers through ctx.fetch with the admin's private endpoint list as allowPrivateHosts, and runs approved commands through ctx.ssh.withConnection. Hosts come from ctx.hosts (proposals use create, update and delete, after checking the approving user's core hosts.* permission), channels from ctx.notify.channels(), and everything else from optional services: snippets.access, fleets.access, automations.access, workspaces.saved, network-topology.graph, terminal.history and homepage.items v1 (list(), which the homepage plugin provides, B19). A tool names the service it needs, and a tool whose service has no running provider is not offered to the model at all. B18 made the write methods of snippets.access and fleets.access check the same permission their routes do (snippets.create/edit/delete, fleets.manage), since a proposal runs as the approving user. Its settings are the admin globallyEnabled and privateEndpoints, the user enabled and allowReadOnlyCommands plus a custom providers field, and the host enableAiAssistant, which decides whether its terminal.toolbar button and terminal.sidePanel appear. ai-settings-migration.ts moves the 2.8 values and keys. The shell knows nothing about it: availability is the plugin being enabled, ai.use, and those settings.

Homepage (B19) is the widget canvas and the dashboard's service links. It imports nothing from core. It adopts homepage_items, homepage_layouts and dashboard_service_links, keeping the legacy sync wire names (homepageItems, dashboardServiceLinks). Its outbound routes (favicon, RSS, ping, proxy widgets) all go through ctx.fetch, so they keep the SSRF guard every other outbound call gets. It provides homepage.items v1 (list()), which the AI assistant reads as an optional service. Core's own uptime, recent-activity and database-health routes are unrelated to the canvas and stayed on the main server (database/routes/dashboard-routes.ts, mounted at /dashboard); only the widget canvas and the service-links table left with this plugin. The two ports it used to run on (30006 dashboard, 30012 homepage) are gone.

Both dashboard cards (service_links, homepage_preview) and the ?view=homepage full-screen link are the plugin's own, registered through registerDashboardCard/registerTab. registerDashboardCard gained defaultPanel ("main" or "side"), so a preset that lays out cards for a new user reads a plugin's own card's height and panel from the registry (service_links asks for the side column) instead of a core file having to spell every plugin's card id and its layout by hand. The dashboard's "Dashboard / Homepage" toggle is generic: it renders whatever a plugin contributes to a new dashboard.secondaryView action slot (component kind, titleKey for the tab label), so core carries no homepage-specific header or copy-link button of its own. A widget any plugin registers with app.registerHomepageWidget still goes into a registry core owns (src/ui/plugin-host/homepage-widget-registry.ts), the same as dashboard-cards-registry.ts, so docker's, tunnels', file-manager's and host-metrics's own homepage widgets keep working with homepage disabled; only the canvas that renders them is gone. The frontend SDK gained useHostActions() (every host action any plugin registered, for a widget that offers a picker over what it can do with a host, such as Quick Connect), useActivityTypes() and activityTarget(type) (the recent-activity type list and its icon/tab/label, for a filter or an activity feed widget), and useHomepageWidgetTypes()/homepageWidgetType(id) (the read side of registerHomepageWidget, for the canvas and its add-widget menu). @termix/plugin-sdk/ui gained PluginViewPlaceholder, and WidgetTitle and runVisibleInterval moved from the homepage feature directory to src/ui/lib/ now that a plugin, not a core feature, is their biggest caller. The shared host/selectHost/noHostConfigured/loading strings several widget-owning plugins relied on through the homepage namespace's core fallback moved to core's common.*, which is what that fallback is actually for.

Wake-on-LAN (B20) imports nothing from core. It owns no table: macAddress and broadcastAddress are host settings (contributes.settings.host), moved out of ssh_data.mac_address/wol_broadcast_address by a boot migration in src/backend/utils/crypto-migration/wake-on-lan-settings-migration.ts. A "Wake" host action sends the magic packet, gated on network:broadcast (medium, added to the capability catalog this step). It provides wake-on-lan.send v1 (wake(hostId): Promise<void>, which resolves the host's MAC and broadcast address itself and checks the caller's own wake-on-lan.send permission), which automations (B17) already declared as an optional dependency.

Secret sources (B20) imports nothing from core. It adopts secret_sources (rename only; the access token is not a column - see below), and provides no service, only ctx.credentials.registerSecretResolver("op", resolve). The 1Password Connect client, its egress allowlist (an admin privateEndpoints textarea, the same shape as the ai plugin's) and the source manager UI (a component slot core's host editor and credential editor render through app.registerComponent("secret-sources.hint" | "secret-sources.manager", ...) and PluginComponent, replacing the static imports those two core files used to carry) all moved in. The access token follows the pattern the ai plugin set for provider keys: it lives in ctx.secrets under "source:<id>" rather than in the table, so it is encrypted with the installation key rather than the owner's data key and is readable whenever the plugin runs, not only while the owner is signed in. A shared source's token is still only ever read for its own owner: resolveConnectReference and the /test route both go through ctx.asUser(<owner>) before reading it, so a recipient using a shared source never touches the raw value directly. src/backend/utils/crypto-migration/secret-sources-token-migration.ts moves an existing owner-DEK-encrypted token out of the column and into ctx.secrets at boot, the same shape as ai-settings-migration.ts's provider key move. The /users/secret-source-private-endpoints route and its admin UI are gone; the allowlist is the plugin's own admin setting now.

Host status is core

The status dot in the host list, the dashboard and the homepage widget is core, in src/backend/hosts/status/. B16 moved it out of host-metrics's poller. Core opens a TCP connection to every host with status checks on (status_check_enabled, a core host column), on the host's own interval (status_check_interval) or the admin default (global_status_check_interval under GET/PUT /host/status/settings), through core's jump host chain when the host has one. It never logs in: on RADIUS or Duo backed devices a login fires a real 2FA push. That gives "reachable" or "offline". A host is "online" when a login worked: an open session (ctx.hosts.trackSession) or a plugin's ctx.hosts.status.reportLogin. Polling is demand driven: a user asking for GET /host/status starts their own hosts. Core emits host.status ({ hostId, ownerUserId, status, previous, online, reason? }) when a status changes, and host.key.updated ({ hostId }) when a changed host key is accepted. The status half of the old stats_config moved into the two columns (host-status-config-migration.ts); its metrics half is host-metrics's.

Host metrics (B16) polls only hosts somebody is looking at (a tab, a dashboard row, a homepage widget, or another plugin through the host-metrics.viewers service, v1: register(hostId), heartbeat, unregister, all as the caller). It samples over ctx.ssh pools and timers from ctx.schedule, keeps the adaptive metrics concurrency (one worker per 20 polled hosts, 5 to 50, METRICS_POLL_CONCURRENCY overrides), and waits for core to call a host reachable before the first heavy sample. It emits plugin.host-metrics.snapshot ({ hostId, ownerUserId, metrics }) for every sample, plugin.host-metrics.status ({ hostId, ownerUserId, state, previous }, state collecting, auth_failed, host_key_changed or unreachable) when collection changes, and plugin.host-metrics.health-check ({ hostId, userId, checkId, ok, detail? }) for each health check result; automations subscribes to them. A plugin adds data to every sample by providing host-metrics.collectors (v1: collect(client, hostId)) under its own provider name; the result lands under extra[<name>]. Proxmox does not: its stats come from the Proxmox API, not from SSH on the host. The intervals, history retention and the new-host default are admin settings, the temperature unit a user setting, and the rest of stats_config host settings.

Alerts (E1) is the one place every alert lands, from any plugin, official or not. A plugin sends with ctx.notify.send({ title, body?, severity?, category?, audience?, channelIds?, link?, dedupeKey?, context? }) and never needs to know the alerts plugin exists:

  • Core resolves audience to user ids: nothing means the acting user, { userId } one user, "admins" every admin, { permission } every user who holds it. It then hands { source, recipients, notification } to the hub, the one plugin that called ctx.notify.serve(hub) with the notify:hub capability (high risk: it sees every alert). A second hub is refused, and a hub whose grant is withdrawn is ignored. With no hub running, send reaches nobody and channels() answers [], so a sender never checks. notify.hub is core's word and names no plugin.
  • category is <pluginId>.<kind> by convention (acme-ssl.renewal_failed, automations.run_failed) and defaults to the sending plugin's id; users route by it. severity is info, success, warning (the default) or critical. link.tab names a tab type the inbox opens; link.url must be http(s). An alert with a dedupeKey is dropped while an unread one with the same key is in that user's inbox.
  • The alerts plugin stores each alert once per recipient in p_alerts_items, pushes it to the user's open GET /stream (server-sent events), and sends it to that user's channels: the ones their delivery rules match (p_alerts_rules: a category pattern such as docker.* and a lowest severity) plus the channelIds the sender named, only ever the recipient's own. Channels (p_alerts_channels: webhook, ntfy, Discord and email over the admin's SMTP server) keep their config sealed with ctx.secrets.seal, so an alert sent at night with no user signed in is still delivered. Private addresses need the channel's opt-in and an exact host in core's notification_private_endpoint_allowlist, which it reads with ctx.settings.readCore.
  • Termix announcements (termix-alerts.json in the Docs repo, which core fetched in 2.8) come into each inbox the first time the user looks, and one the user deletes is recorded in p_alerts_dismissed (adopted from core's dismissed_alerts) and never comes back. They never go to channels.
  • The upgrade copies core's notification_channels into p_alerts_channels with the same ids and reseals each config once its owner's data key opens (upgrade/notification-channel-migration.ts, at boot and at a password login). The old table stays, emptied of configs, until 3.0.0.
  • The frontend puts a bell at the bottom of the rail (a rail item with placement: "footer" and a useBadge hook showing the unread count), an inbox in its panel, and inbox, channels and delivery rules in its tab. New alerts pop up through a shell.overlay component, as far as the user's popups setting allows. Other plugins open it with the alerts.open and alerts.openChannels actions. Automations sends its notify steps and failed runs here, and acme-ssl a failed renewal to every admin.

10. Lifecycle

activate(ctx) creates all state. Everything registered through ctx or app is disposed automatically on deactivate. See Lifecycle rules.

11. Tests

The security regressions for the runtime live in src/backend/tests/plugins/security/ (one file per area of the D2 review) and src/ui/tests/plugin-host/security/. A fix to plugin code gets its test in that plugin's own tests/, like everything else.

Each plugin has plugins/<id>/tests/backend (node) and plugins/<id>/tests/frontend (jsdom), run by its own vitest config:

// plugins/<id>/vitest.config.ts
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";

export default pluginVitestConfig(import.meta.url);

The preset supplies both projects, the shared setup file (@termix/plugin-sdk/testing/setup, which core's vitest.setup.ts re-exports so there is one copy) and the @/ alias the frontends still use. The setup also keeps a test server started on port 0 off the ports fetch refuses, which Windows can hand out.

npm run test runs core only, npm run test:plugins runs every plugin and npm run test:all runs both. Core tests (src/backend/tests, src/ui/tests) never test plugin code; they may test the plugin runtime with fixture plugins, which is why src/backend/tests/plugins/ still holds the loader, manifest, capability, registry, service, secret and shutdown suites.

@termix/plugin-sdk/testing provides createMockCtx(), which enforces capabilities the way the runtime does, createFakeContext() for tests that do not care about the gates, createTestDb(pluginDir) for a real database, and renderWithApp(plugin, options) for frontend tests.

createTestDb(pluginDir, { before }) opens an in-memory SQLite with stub users (with is_admin, B12), ssh_data, roles and user_roles tables and foreign keys on, runs before (seed a legacy table there to test an adoption), then applies the plugin's own migrations/sqlite with the runner's splitter. ctx.db.refs() against this database returns the same four, built by buildRefTable, which maps each declared property to its real snake_case column (displayName to display_name) the same way buildTable does, so a repository written against the real schema's property names works unchanged in a test. Pass its database to createMockCtx({ db }): define builds the real table, client is Drizzle, and persisted counts persist() calls. The mock also takes router (for example () => express.Router(), so routes are served for real), permissions (enforced by ctx.rbac, answering 403 like core; omit it to pass everything) and hosts (PluginHostSummary[], what ctx.hosts.list/get/checkAccess serve - B8 added this option, since it existed on createFakeContext already but was not forwarded), and returns setActor(userId) for a test middleware and services for what the plugin provided. B12 added a services option seeding other plugins' services for a consumer's test, keyed <service> or <service>#<provider>, and the doubles now answer a missing service with an empty handle, like the runtime, instead of throwing. B8 also gave ctx.registry on both doubles a real in-memory backing (it was a no-op stub before), so provide/consume/revoke round-trip the way the real registry does. plugins/workspaces/tests/backend/helpers.ts is the worked example.

The doubles' wsRoutes (B15) record each route's handler (or the upgrade callback) and options, so a test can hand a route a fake socket; docker's tests/backend/console.test.ts opens a console, disposes the plugin and activates it again that way.

renderWithApp also takes api (a stub for app.api and usePluginApi()), layout (what app.tabs.getLayout() returns) and ready (fire app.tabs.onReady). app.tabs.applyLayout is recorded in shellCalls and runs the shell's real restore rules, openedTabs() lists what it opened, and renderOpenedTab(i) renders one the way the shell would, placeholder included. C1 added registered.loginMethods() / .secondFactors() and renderLoginMethod, renderSecondFactor and renderEnrollment, and the backend doubles take refuseEnrollment to make recordEnrollment refuse the way core's policy does. renderWithApp activates the plugin against core's real registries, records what it registered and the shell calls it made, renders any tab, panel, card, host editor section, settings component or slot, and deactivate() disposes it all. Every bundled plugin has a tests/frontend/activate.test.tsx built on it.

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.

C5 moved Vault, the last SSH auth method core still registered, into its plugin and deleted src/backend/auth/legacy-providers.ts. C4 had already deleted src/ui/auth/legacy-auth-ui.tsx. Core registers only the base types and branches on no plugin type name.

C1 moved TOTP and passkeys out:

  • totp registers the totp second factor. It owns p_totp_enrollments (one row per user: the active secret, an unfinished setup's secret and the backup codes, all sealed with ctx.secrets.seal), so verify reads them during login with no acting user. Its routes at /plugin-api/totp/ (status, setup, enable, disable, backup-codes) re-authenticate with a TOTP or backup code, never the account password, which a plugin cannot see. enable calls ctx.auth.recordEnrollment before it turns anything on, so core's policy can refuse it cleanly.
  • webauthn registers the passkey login method (inline on the login screen) and adopts webauthn_credentials. A passkey is a login method only, as in 2.8: an assertion whose authenticator verified the user returns mfaSatisfied, which skips core's second-factor step; without it the user still gets their factors. The relying party id and origin come from the request exactly as before (no core setting holds them); the relying party name is ctx.settings.readCore("app_name"). POST /plugin-api/webauthn/authenticate/options is public; the assertion goes to core's /users/auth/passkey/verify.
  • src/backend/utils/crypto-migration/totp-migration.ts takes a 2.8 database across: every core/totp enrolment row (A8) becomes totp/totp, every user with users.totp_enabled gets one, and once the plugin's table exists each secret and its backup codes are decrypted with the user's data key, sealed into it, and the users columns cleared. The rows move even while the plugin is off, so those users fail closed. The users TOTP columns left schema.ts, and the drizzle migration that would drop them is a no-op, like an adopted table.
  • Core keeps no TOTP knowledge. totp_enabled in the login body and /users/me is still sent under that name for 2.8 clients (Termix-Mobile, older remote-sync peers) but means "has any second factor"; the admin user list sends second_factor_enabled. Trusted proxy login, "disable password login" and a password reset that wipes data go through user_second_factors and resetUserSecondFactors. /users/admin/totp/disable is gone; the generic admin reset covers it. AuthManager.unlockWithSystemKey (was authenticateWebAuthnUser) opens the data key for any identity that carries no password.

C2 moved external logins out:

  • sso registers the oidc login method: OIDC, GitHub and Google, one button per enabled provider (the method's instances), plus the provider configured through the OIDC_* environment variables as instance 0. It adopts sso_providers as p_sso_providers, ignoring any ldap rows left in it. Its public routes are /plugin-api/sso/start (sends the browser to the provider), /callback (GET and POST), /backchannel-logout and /config; provider management is /plugin-api/sso/providers, gated on sso.manage, and renders as the custom providers field in the plugin's Admin Settings section. Login state (nonce, PKCE verifier, return address) lives in ctx.kv for ten minutes.
  • The redirect URI is <base>/plugin-api/sso/callback. Providers that existed before 2.9 keep sending <base>/users/oidc/callback, the URI their identity provider was registered with, because the sso plugin's second migration sets legacy_callback on every adopted row; the env provider always does. Core answers that old URL with a 308 to the plugin's callback, and the token exchange sends the same redirect URI the authorize request did, so strict providers accept it. The admin page shows each provider's URI and switches it to the new one once the admin has registered that. /users/oidc/backchannel-logout is a 308 to the plugin as well.
  • ldap registers the ldap form method, one form per directory, in its own p_ldap_providers. utils/crypto-migration/ldap-provider-migration.ts copies the 2.8 LDAP rows out of sso_providers (adopted or not) with their ids and deletes them there. It runs from the loader whenever a plugin's migrations applied something (plugin-data-moves.ts), so the rows are in place before the ldap plugin first activates, at boot or when it is enabled later. LDAP identities are stored under provider ldap:<id> so their ids can never clash with SSO ones; external-identity-migration.ts writes them that way and the LDAP migration renames any bare ones.
  • Both return 2.8's users.oidc_identifier value in legacy, so pre-2.9 accounts are found and new ones keep the column filled. The sso plugin passes its group role map as roles and the id token's sub/sid as logoutClaims; /backchannel-logout ends those sessions with ctx.auth.revokeSessions. LDAP counts failed binds with ctx.auth.loginRateLimit, the limiter password login uses.
  • Core keeps provisioning (the allowed-users matcher is auth/allowed-users.ts), oidc_auto_provision, silent sign-in (it starts the first redirect method), account linking and the trusted proxy rule: every external method is refused while trusted proxy login is on, and /users/proxy-login refuses while any external method lists an instance. The 2.8 client routes /users/oidc/authorize, /users/oidc-config (307 to /plugin-api/sso/config), /users/sso-providers and /users/ldap/login live in database/routes/auth-compat-routes.ts and only forward to the registered methods, so with the plugins off they answer 404.

C3 moved OPKSSH and Warpgate out:

  • opkssh registers the opkssh SSH auth type. It adopts opkssh_tokens as p_opkssh_tokens, the certificate and key sealed with ctx.secrets.seal. Rows 2.8 wrote were encrypted with the user's data key, which a plugin cannot open: they read as expired and the user signs in once, which is what a cached certificate is for (at most 24 hours old).
  • The binary comes from ctx.process.ensureBinary: the copy the Docker image bakes into /app/opkssh-bundled (OPKSSH_BUNDLED_DIR) when its checksum matches, else the one in the plugin's data folder, else a download pinned to the release's SHA-256. opkssh login runs through ctx.process.run and is killed on cancel, close and deactivate. The config lives at <DATA_DIR>/plugin-data/opkssh/config.yml; redirect_uris there are still the localhost listener candidates only, and the public callback still goes to OPKSSH as --remote-redirect-uri.
  • The chooser and callback pages are public plugin routes (/plugin-api/opkssh/chooser/:id/*, /callback, /callback/:id/*). New installs register <base>/plugin-api/opkssh/callback with their identity provider. utils/crypto-migration/opkssh-config-migration.ts (run from plugin-data-moves.ts) copies a 2.8 .opk/config.yml in and turns on the admin setting legacyCallback, so an upgraded install keeps sending <base>/host/opkssh-callback, which core answers with a 307 to the plugin (database/routes/host-compat-routes.ts). The admin page shows the URI to register.
  • The sign-in dialog is a terminal.overlay contribution. The terminal knows no sign-in by name: it forwards <interaction>_start_auth, _cancel and _auth_completed to ctx.ssh for any provider.
  • warpgate registers a keyboard-interactive handler (ctx.auth.registerKeyboardInteractiveHandler) and the host setting useWarpgate, which warpgate-settings-migration.ts fills from ssh_data.use_warpgate (and from hosts still on auth type warpgate). The column is out of schema.ts; its drizzle drop is a no-op.
  • A handler's browser round is the generic browser decision ({ id, label, url, code, instructions }). The terminal sends it as <id>_auth_required and takes <id>_auth_continue, so Warpgate keeps its 2.8 message names for Termix-Mobile; the file manager and docker answer requires_browser_sign_in and continue on connect-browser-sign-in.
  • @termix/plugin-sdk/ssh-certs (applyCertificateAuth) replaces core's opkssh-cert-auth.ts: opkssh, step-ca, core key auth with a CA certificate, and vault use it.

C4 moved Step-CA out:

  • step-ca registers the stepca SSH auth provider. Termix never runs the step binary: the plugin talks to the CA's HTTPS API and the identity provider through ctx.fetch (network:outbound), using its tls option to bootstrap the CA root by fingerprint and pin it afterwards.
  • Issued certificates live in its own table, p_step_ca_certs, sealed with ctx.secrets.seal, until the certificate's validBefore. 2.8 cached them in opkssh_tokens; those are left to expire.
  • The CA URL, fingerprint, provisioner and private endpoint list are admin settings. step-ca-settings-migration.ts copies the 2.8 step_ca_* settings rows in and turns on legacyCallback when a CA was configured, so an upgraded install keeps sending <base>/host/step-ca-callback, which core answers with a 307 to /plugin-api/step-ca/callback.
  • With REDIS_URL set, a callback that lands on another instance is handed to the one that started the sign-in through Redis (TERMIX_STEP_CA_REDIS_PREFIX), as in 2.8.
  • The sign-in dialog is a terminal.overlay contribution speaking stepca_*.

C5 moved HashiCorp Vault out:

  • vault registers the vault SSH auth provider. It adopts vault_profiles and vault_tokens as p_vault_profiles and p_vault_tokens. Profiles keep their ids and sync ids; cached certificates are sealed with ctx.secrets.seal, and the ones 2.8 wrote with the user's data key do not unseal, so they are dropped and cost one sign-in. A fresh install has no foreign key from a token to its profile (the SDK has no builder for one to a plugin's own table), so deleting a profile deletes its tokens in code.
  • A host's profile is the profileId host setting (hidden, edited in the plugin's registerSshAuthEditor editor, which also holds the profile manager where the host editor always had it). vault-settings-migration.ts copies ssh_data.vault_profile_id into it; the column is out of schema.ts and its drizzle drop is a no-op.
  • Vault is reached through ctx.fetch, with the profile's own Vault host passed as allowPrivateHosts, since Vault servers are usually internal.
  • Profile routes are /plugin-api/vault/profiles, gated on vault.use; sharing a profile needs vault.share (admins by default), which replaces 2.8's admin check. Only the owner edits or deletes one.
  • The sync entity keeps its wire name vaultProfiles and order 20. Hosts no longer reference it, so core's host entity lost its vaultProfileId reference and sync-references.ts has no Vault case.
  • The callback is /plugin-api/vault/oidc/callback (public). The boot migration decides legacyCallback once: on when the 2.8 database used Vault, so Termix keeps sending <base>/vault/oidc/callback, the URI Vault roles allow, which core answers with a 307 to the plugin (registerVaultCompatRoutes in host-compat-routes.ts; nginx proxies only that path now).
  • The sign-in dialog is a terminal.overlay contribution. It keeps the 2.8 message names (vault_auth_url, vault_completed, vault_error) for Termix-Mobile.

C6 moved Termix Identity out. It never was an SSH auth type: issued certificates are downloaded once and never stored, and no connect path reads them, so termix-identity is a standalone feature plugin with no provider.

  • It adopts termix_identities, termix_identity_keys and termix_identity_ca as p_termix_identity_identities, _keys and _ca. The migrations keep, by hand, the links from keys and CA to their identity (cascade) and from a key to ssh_credentials (set null).
  • The CA private key is sealed with ctx.secrets.seal (secrets:own), so issuing a certificate no longer needs the owner's data key unlocked. termix-identity-ca-migration.ts reseals 2.8 rows, which core encrypted with the owner's data key; it runs from runPluginDataMoves right after the adoption and again at boot for a row whose key was not open.
  • The resolver, /plugin-api/termix-identity/u/<handle>[/<ALGO>|/ca], is public through ctx.http.router({ public }). The 2.8 URLs under /termix-id/u/ answer 308 to it (registerTermixIdCompatRoutes in host-compat-routes.ts; nginx proxies only /termix-id/u/ now), because servers fetch them from provisioning scripts. The management routes moved and are gated on the new termix-identity.use (admins and users).
  • Publishing a saved key and saving a generated pair go through ctx.credentials.listSshKeys and createSshKey (C6, see the ctx surface): the plugin never sees a stored private key.
  • The rail item keeps its id termix-id, so saved layouts, hidden-rail preferences and presets carry over, and uses app.desktop to stay hidden in a standalone desktop app, as the shell did before.

One SSH connect pipeline

Every SSH connection, core's and plugins', goes through src/backend/hosts/connect/:

  1. resolveHostById resolves the host for the acting user: RBAC, owner-key decryption, shared-host overrides and shared agent, and op:// external secret references.
  2. buildConnectConfig builds the ssh2 config: the defaults for the connection's profile (terminal, session, stream, forward, background or jump), the host key verifier, then the auth provider's prepare.
  3. openSshTransport does port knocking, the Cloudflare Access tunnel, the jump host chain (every hop through steps 1 and 2) or SOCKS5.
  4. connectHost connects with a keyboard-interactive handler: a prompt channel when a person can answer (terminal, file manager, docker), otherwise the stored password for password prompts and nothing else.

Keyboard-interactive rounds are classified once (classifyKeyboardInteractive): a plugin's keyboard-interactive handler first, then push MFA, TOTP, and plain input. The transport decides how to ask.

A host whose authType has no provider fails with This host uses <type>, which needs the <plugin> plugin (the plugin is found from contributes.auth.sshAuthTypes, including disabled plugins), and the host editor shows the same notice.

SSH auth providers

ctx.auth.registerSshAuthProvider({
  type: "corp-ca", // stored in ssh_data.auth_type
  labelKey: "authType",
  requiresSecret: false, // shared hosts: does a recipient need a secret
  needsUserInteraction: true, // a browser sign-in or a person at the keyboard
  supportsBackground: false, // can metrics and fleets poll it unattended
  credentialType: false, // also offered as a stored credential type
  interaction: "corp-ca", // what its outcomes call the browser step
  fields: [/* settings fields */],
  connectOptions: (host, purpose) => ({/* config overrides */}),
  prepare: async (config, host, env) => {
    // fill config (env.client is the ssh2 Client, for certificate patching)
    return { status: "ready" };
    // or { status: "interaction-required", interaction: "corp-ca", message }
    // or { status: "error", code: "missing-secret", message }
  },
  onKeyboardInteractive: (round, host) => null, // claim a prompt style
  onAuthFailed: (host, env, context) => undefined, // clear caches, retry once
  onBanner: (banner, host, env) => undefined,
  // claim an ssh2 "banner" event during the handshake, for a server that
  // holds the connection open pending an out-of-band step (Tailscale SSH
  // check mode). Return { action: "hold", timeoutMs, message, details? } to
  // extend the connect timeout and have the terminal send
  // "<type>_check_required" with `details` merged in, or
  // { action: "release", details? } once the banner says it is done, which
  // sends "<type>_check_completed". Undefined leaves the banner unhandled.
  // B3 (tailscale) is the only caller today.
  startInteraction: async (request) => {}, // start the browser step
});

The type must be listed in contributes.auth.sshAuthTypes, and the plugin needs auth:provide. The grant is checked on every prepare. fields are the plugin's own host settings (declare them in contributes.settings.host too): the host editor renders them when the plugin registered no editor, and prepare reads them with ctx.settings.getHost(host.id, key). A frontend can instead draw its own editor with app.registerSshAuthEditor.

A transport that can show a browser step sends <interaction>_auth_required and later calls the provider's startInteraction for <interaction>_start_auth.

A server that speaks its own prompt style on any host, whatever its auth type (an SSH bastion asking for a browser approval), is a keyboard-interactive handler instead (C3):

ctx.auth.registerKeyboardInteractiveHandler({
  id: "gate", // listed in contributes.auth.keyboardInteractive
  label: "Gate", // shown in the sign-in dialog title
  detect: (round, host, settings) =>
    /gate/i.test(round.name)
      ? { kind: "browser", url, code, instructions: round.instructions }
      : null,
  autoAnswerPasswords: (host, settings) => settings.enabled === true,
});

settings is the plugin's own host settings for the host, defaults filled in: the host resolver puts every plugin's host settings on the connect host (pluginSettings) because both hooks run synchronously inside ssh2's callback, and the handler only sees its own slice. Needs auth:provide; the declaration is checked at registration, and deactivate removes the handler.

Login methods and second factors

ctx.auth.registerLoginMethod({
  id: "corp-sso",
  labelKey: "signIn",
  kind: "redirect",
  external: true, // governs the "second factor after external logins" setting
  describe: async () => [{ id: "main", label: "Corp", enabled: true }],
  start: async (request, instanceId) => ({ redirectUrl }),
  callback: async (request) => ({
    kind: "external",
    provider: "corp",
    subject: "123",
    email,
    name,
    groups,
    isAdmin,
    allowedUsers,
    returnTo,
    rememberMe,
  }),
});
ctx.auth.registerSecondFactor({
  id: "pin",
  labelKey: "pin",
  isEnrolled: async (userId) => true,
  challenge: async (userId) => ({/* for the UI */}),
  verify: async (userId, body) => body.pin === "1234",
  reset: async (userId) => {},
});
await ctx.auth.recordEnrollment(userId, "pin"); // and removeEnrollment

A method never issues a session. It returns an identity: an existing user id, or an external identity. Core then:

  1. finds the user through user_external_identities (provider, subject), or provisions one with the core rules: first user is admin, the allowed-users list is checked on every sign-in, provisioning needs SSO auto-provisioning, admin approval is a hook that is off today, the admin group and role map are kept in step;
  2. unlocks the data key (the password for password logins);
  3. runs second factors: every row in user_second_factors plus any factor that reports itself enrolled. Fail closed: a row whose plugin is disabled or gone refuses the login ("contact an admin"). A trusted device or a method that already proved a factor (a verified passkey) skips the step. This step is skipped entirely for a login method whose registration sets external: true (OIDC, LDAP; a plugin declares it the same way) unless the admin setting "ask for a second factor after external logins" is on. Off by default, matching the behaviour before second factors applied to every method: a local method (password, a passkey, a plugin's own local form) always runs them;
  4. issues the JWT, cookie, trusted device and the login audit line.

Routes, all under /users/auth: GET methods (public), GET :methodId/start, GET|POST :methodId/callback, POST :methodId/verify, POST second-factor/:factorId/challenge, POST second-factor/:factorId/verify. A redirect method's provider sends the browser back to /users/auth/<methodId>/callback, or to a public route of the plugin's own that calls ctx.auth.completeRedirectLogin(methodId, req, res), which runs the same callback and pipeline (C2, for a URL that has to stay stable in the provider's configuration). The older routes (/users/login, /users/oidc/*, /users/ldap/login) are thin wrappers over the same pipeline.

Identity extras (C2). An external identity may also carry roles ({ desired, managed }: core adds the desired roles and removes managed ones the user no longer maps to), logoutClaims (providerId, sub, sid, stored on the session for ctx.auth.revokeSessions), legacy (the 2.8 oidc_identifier and sso_provider_id, only for a plugin that took over a 2.8 method) and rateLimitKey (cleared on success; keys are kept apart per plugin). Core copies only these documented fields, so a plugin cannot set core-only ones. ctx.auth.countLinkedUsers(provider) answers how many users sign in through a provider, for refusing to delete one still in use. ctx.http.baseUrl(req) is the install's public URL (origin plus base path, honouring OIDC_FORCE_HTTPS) for building callback URLs. /users/totp/verify-login stays for 2.8 clients: it answers with the factor named in factor, or the user's first required factor, and core never names one itself.

Enrolment policy (C1). ctx.auth.recordEnrollment refuses with a LoginMethodError (409) while trusted proxy login is on or the password login setting is off, the rules the 2.8 TOTP route had, now applied to every factor. When it records one it also revokes the user's other sessions and forgets their trusted devices, so nothing that skipped the factor before keeps skipping it. The session to keep is the request's: the HTTP middleware puts the session id next to the actor (getActorSessionId).

An admin clears a user's factors, including orphaned ones, with DELETE /users/admin/:userId/second-factors (audited as admin_reset_second_factors).

Password login is always a method. The admin "allow password login" setting still turns it off, but only while another method (an enabled SSO instance, or trusted proxy login) can sign someone in; otherwise it stays on with a warning so nobody is locked out.

Login UI before sign-in

The login screen loads the frontends of enabled plugins that contribute login methods or second factors from the public GET /plugins/public-manifest (ids, versions, asset info and contributes.auth only), before anyone signs in. app.registerLoginMethod draws a button or form for a method the server lists (props: instances, rememberMe, submit, startRedirect, complete); a redirect method with no UI gets one button per instance. app.registerSecondFactorUI draws the challenge (props: verify, challenge, cancel) and, with enrollment, a section in Settings > Security. C1 gave app.registerLoginMethod the same enrollment (a passkey is registered there) and placement: "inline", which also draws the method under the password form; the login screen no longer knows the passkey method by name. A server that sends a second-factor step without naming its factors gets every factor UI registered on the screen. At activation before sign-in app.api calls fail with 401, so do nothing there but register.

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.

The layout is fixed, and the directory name must equal the manifest id:

plugins/<id>/
  manifest.json            id, capabilities, contributions
  package.json             @termix-plugin/<id>, private, scripts call the CLI
  tsconfig.json            extends @termix/plugin-sdk/tsconfig.plugin.json
  vitest.config.ts         one line, the SDK preset
  src/backend/index.ts     exports activate(ctx) and deactivate()
  src/frontend/index.tsx   exports activate(app) and deactivate()
  locales/en.json          English strings; Crowdin writes locales/translated/
  migrations/{sqlite,postgres,mysql}/  owned tables, one .sql per dialect
  migrations/snapshot.json        diff basis for the generator
  src/backend/tables.ts           defineTable() definitions
  tests/backend/           vitest, node
  tests/frontend/          vitest, jsdom
  README.md  CHANGELOG.md  icon.svg
  dist/                    build output, gitignored

Which plugins ship, and from where

docker/bundled-plugins.json lists every plugin the image ships and where each comes from. npm run build:plugins (scripts/build-plugins.cjs) reads it and stages each one in dist/plugins/<id>/:

{ "id": "tunnels", "source": "workspace" }
{ "id": "tunnels", "source": "tmxplug",
  "url": "https://github.com/Termix-SSH/termix-plugin-tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
  "sha256": "<64 hex>" }

A workspace entry is built from plugins/<id>/. A tmxplug entry is downloaded (cached under node_modules/.cache/termix-bundled-plugins/) or read from a local path, refused unless its sha256 matches the one pinned in the file, and unpacked. The build fails on a plugins/ folder nobody listed, a workspace entry with no folder, or a tmxplug entry whose folder still exists, so the two can never both ship.

Switching a plugin over once its own repo exists:

  1. Tag a release in the plugin's repo. Its release workflow publishes <id>-<version>.tmxplug and the .sig.
  2. Replace the plugin's entry with source: "tmxplug", the release url and the sha256 the workflow printed (or sha256sum of the download).
  3. Delete plugins/<id>/ and run npm install so the workspace goes away.
  4. Run npm run build:plugins and check dist/plugins/<id>/ holds the plugin.

A tmxplug plugin is not in the Vite dev server's plugin glob, so npm run dev does not hot-reload it; its built frontend still loads from /plugin-assets/<id>/.

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.

Moving a host column into a plugin

A6 built the host-scope settings path but moved no columns; each Phase B step moves its own. ssh_data still holds enable_docker, stats_config, web_ui_config, enable_proxmox, enable_rdp, mac_address and the rest, and the hardcoded host editor tabs still read them. The order matters, because dropping a column before its data has moved loses it:

  1. Declare the fields in the plugin's contributes.settings.host, keeping the key names the form already uses so the editor tab can be deleted rather than rewritten.
  2. Write a boot migration that copies each row's column into host-scope plugin_settings for that plugin, keyed by the host id as text. Make it idempotent by checking for an existing row first, the way tailscale-settings-migration.ts does, and cover it with a test that runs it twice.
  3. Switch the plugin's backend to ctx.settings.getHost and delete the hardcoded editor tab; the schema-driven Plugins group replaces it.
  4. Only then drop the column, in lockstep across all four artifacts: the schema.ts declaration, the db/index.ts bootstrap DDL, a drizzle migration per dialect, and npm run schema:generate for the pg and mysql variants. The two bootstrap guard tests fail if these disagree.

A column that is still read anywhere in core is not ready to be dropped. Ship the copy and the switch first, and drop it in a later step if that is safer.

Order at boot (B14). On Postgres and MySQL, core's drizzle migrations run while the database opens, and the copies into plugin_settings run later, after the plugins table exists. A real DROP COLUMN in step 4 therefore deletes the data before it is copied. So step 4 removes the column from schema.ts, the pg/mysql variants and the db/index.ts bootstrap, and the generated drizzle migration's DROP COLUMN statements are replaced with a comment and SELECT 1;, exactly as for an adopted table. The column stays in the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL through utils/crypto-migration/raw-rows.ts, whose selectRows and runStatement work on all three engines: getDb().all() exists only on SQLite. Remote desktop's host options and user_preferences.rdp_defaults are the first columns handled this way. B16 split stats_config the same way, into two core columns and host-metrics's host settings. Adding columns and dropping one in the same drizzle-kit run makes it ask whether that is a rename, which it cannot do without a terminal, so B16 generated two migrations: the added columns first, then the no-op drop.

D0 dropped the rest this way (the file manager, tunnels, web endpoint, terminal, tmux monitor, session sharing and session recording columns and the dead show_*_in_sidebar ones), rewrote the two proxmox drops that really ran DROP COLUMN on Postgres and MySQL, and moved every copy onto selectLegacyRows, which reads a column schema.ts no longer declares on all three engines and answers nothing on a database that never had it. A 2.8 boolean read raw goes through legacyFlag, since SQLite and MySQL hand back 0 and 1. The copies live in src/backend/plugins/boot-migrations.ts (runPluginDataMigrations), which the starter runs after the plugins activate and which runs again when an admin enables a plugin without a restart. The core half of boot (keys, auth, core data migrations) is runCoreBootMigrations in src/backend/boot.ts; the boot integration tests share both with the starter.

A host switch whose 2.8 column defaulted to on declares enableDefault: true (file manager, terminal), because a copy only writes the hosts that changed it, and every other host has to keep reading on.

Before activate, not after. A copy that sets how long a plugin keeps history goes in src/backend/upgrade/plugin-data-moves.ts (runPluginDataMoves), which the loader runs once a plugin's migrations applied and before its activate. A plugin that prunes on activate would otherwise prune by its own default first: session recording's retention (and its 2.8 files) and host metrics' history retention move there.

Proving it (D4)

  • The fixture. src/backend/tests/fixtures/upgrade/ is a 2.8 install: db.sqlite in 2.8's exact schema (sqlite-2.8-schema.sql) with rows in every table LEGACY_TABLE_OWNERS lists, every host flag and 2.8 auth type, TOTP, passkey, SSO and LDAP users, custom roles and the moved settings keys; fixture.env with the keys it was encrypted with; and files/, the recording files its rows point at. rows.ts is the source, and npx tsx scripts/build-upgrade-fixture.ts rewrites the rest, encrypting the columns 2.8 encrypted the way 2.8 did. upgrade-fixture-data.test.ts fails when a legacy table has no rows.
  • The test. src/backend/tests/plugins/upgrade-fixture.test.ts boots it through bootCore (the starter's order: backup, database, core migrations, plugins, data copies) against dist/plugins, checks every row, setting, secret, role, login and sync type through plugin routes, settings and ctx, then boots the same data directory again and checks that nothing changed.
  • Postgres and MySQL. scripts/lib/upgrade-sql.mjs prints every statement the upgrade runs on each engine, snapshotted in fixtures/upgrade/upgrade-{postgres,mysql}.sql by scripts/upgrade-sql.test.ts, which also refuses a DROP TABLE of a legacy table or any DROP COLUMN. bash scripts/upgrade-check.sh starts both servers in Docker, builds the 2.8 schema from the tag's own drizzle migrations, copies the fixture in and runs the same test with TEST_DIALECT set.
  • The backup. backupBeforeUpgrade (src/backend/boot.ts) copies the SQLite file before anything opens it. Each upgrade that rewrites the database has its own marker (ensurePreupgradeBackup({ reason })); 2.9.0's is pre-plugin-runtime, so an install that already has the 2.5 refactor's marker still gets a copy. The test checks the copy is the 2.8 file byte for byte and that a second boot makes no new one.

Left for 3.0.0. These stay in 2.9.0 so a downgrade still works, and go once every install has booted it:

  • The moved ssh_data columns (every plugin host column above, the proxmox, remote desktop, docker, warpgate, vault and stats columns) and user_preferences.rdp_defaults.
  • users.totp_secret, totp_enabled and totp_backup_codes; users.is_oidc, oidc_identifier, sso_provider_id and the 2.8 per-user OIDC columns, and sessions.sso_provider_id, oidc_sub and oidc_sid, once nothing needs the 2.8 shape.
  • The legacy settings rows the plugins copied: the terminal's (terminal_session_*, command_history_enabled, touch_input_settings, terminal_image_*), session_sharing_globally_enabled, guac_enabled, guac_url, the step_ca_* rows and acme_ssl_settings.
  • DATA_DIR/.opk, DATA_DIR/certbot and DATA_DIR/acme-webroot.
  • The 2.8 URLs kept for identity providers and Termix-Mobile: auth-compat-routes.ts (/users/oidc/*, /users/ldap/login and the rest), host-compat-routes.ts (/host/opkssh-callback, /host/step-ca-callback) and the /termix-id/u/ and /vault redirects.
  • The @termix/plugin-sdk/ui typings for plugins built outside this repo, which arrive with the split into separate repositories.

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.

D2 added the discovery rules:

  • RESERVED_PLUGIN_IDS (admin, hosts, credentials, core, termix, plugin, plugins, users, system, sdk) are refused. A plugin's permissions are registered as <id>.<name>, so a plugin called admin would otherwise mint admin.* permissions.
  • backend, frontend and locales must be relative paths inside the plugin: no absolute path, no .., no backslash. The loader also realpaths the backend entry and every migration file and refuses one that resolves outside the plugin, so a symlink cannot point elsewhere.
  • A plugin whose table prefix overlaps a loaded plugin's (foo and foo-bar both own p_foo_bar_*) is refused, bundled first.
  • A user plugin can never take the id of a bundled plugin folder, even when the bundled one failed to load.
  • A uiPresets key must be a plain property name.
{
  "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"] }],
    "permissions": [
      {
        "name": "use", // registered as "example.use"
        "titleKey": "permissions.use.title", // plugin-relative
        "descriptionKey": "permissions.use.description",
        "defaultRoles": ["user"], // system roles only, applied once
      },
    ],
    "auth": {
      // each needs "auth:provide" in capabilities
      "sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
      "loginMethods": ["corp-sso"],
      "secondFactors": ["pin"],
      "secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
      "keyboardInteractive": ["gate"], // keyboard-interactive handler ids
    },
    "settings": {
      "admin": [
        {
          "key": "apiKey",
          "type": "secret", // encrypted at rest, never sent to a browser
          "labelKey": "settings.apiKey.label",
          "descriptionKey": "settings.apiKey.description",
          "placeholderKey": "settings.apiKey.placeholder",
          "group": "settings.group.api", // section heading
        },
        {
          "key": "retries",
          "type": "number",
          "labelKey": "k",
          "min": 0,
          "max": 5,
          "default": 3,
        },
        {
          "key": "browser",
          "type": "custom", // a registered component, for what a schema cannot express
          "component": "devices",
        },
      ],
      "user": [
        {
          "key": "advanced",
          "type": "boolean",
          "labelKey": "k",
          "permission": "tweak", // one of this plugin's own permissions
        },
      ],
      "host": {
        "editorGroup": "ssh", // host editor strip for the generated tab, default "top"
        "editorOrder": 30, // position among that strip's tabs
        "enableKey": "enableExample", // boolean rendered first, gating the rest
        "enableLabelKey": "k",
        "fields": [
          {
            "key": "port",
            "type": "number",
            "labelKey": "k",
            "requires": "enableExample",
          },
        ],
      },
    },

    "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
  "nativeDependencies": ["serialport"], // optional, see "Native dependencies"
}

category is one of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity.

openFrom is one of: rail, host-context-menu, palette.

Field types are boolean, string, number, select, multiselect, secret, textarea, json and custom. labelKey is required for every type but custom, which needs component instead; options is required and non-empty for select and multiselect and rejected elsewhere; min/max belong to number alone; and requires must name a boolean field in the same scope, which includes the host section's enableKey.

Cross-field rules the JSON schema cannot express, checked by parseManifest:

  • engine.api must match the SDK major version this build implements.
  • Every provides[].permission, providesSecret[].permission and contributes.actions[].permission must appear in the plugin's own contributes.permissions, compared against the qualified <pluginId>.<name> form. A permission the catalog never sees is one no admin can grant, so the surface would be invisible rather than denied.
  • requiresSecret[].plugin and both dependency maps must not name the plugin itself, and a plugin cannot be in both dependencies and optionalDependencies.
  • Every contributes.permissions[].name must be unique, must not start with a core group (hosts, snippets, credentials, admin) or with the plugin's own id, and every defaultRoles entry must be admin or user.
  • A contributes.settings field permission written as a bare short name must appear in the plugin's own contributes.permissions. A dotted id is taken as given, because the validator cannot know which other plugins exist.

contributes.panels and contributes.dashboardCards (each { id, titleKey, icon? }) declare the views a plugin owns besides its tabs; the app object refuses to register a view the manifest does not declare. contributes.guest opts a plugin into anonymous guest pages, and contributes.guestViews names the ?view= pages it serves there. provides[].names lists the named providers a plugin registers for a service keyed by provider (see Cross-plugin use). Action contributions and slots accept the kinds button and component.

contributes.auth lists the ids a plugin registers through ctx.auth. A register call for an id not listed here throws. Core also reads sshAuthTypes from disabled plugins, to say which plugin a host needs. secretSchemes (B20) is the same idea for ctx.credentials.registerSecretResolver: core reads it from disabled plugins too, so a reference whose scheme belongs to a disabled plugin can name that plugin in its error instead of saying nothing resolves it.

contributes.hostCapability, provides, requires, providesSecret and requiresSecret are carried forward from v1 unchanged because the service and secret registries read them at activation. Phase B reshapes them.

Removed in A5: contributes.permissionGroup and its defaultForRole, replaced by contributes.permissions. A plugin no longer picks its own group name.

Removed in v2: permissions (replaced by capabilities), sidecars, the capabilities.{backend,frontend,electron,platforms} object (replaced by the backend/frontend/platforms fields), the v1 shape of 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
system:tls critical Replace the server certificate and answer ACME challenges
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, second factor or SSH auth type
device:serial high Open a physical serial or USB device on the Termix server
hosts:write medium Create and change hosts
credentials:use medium Connect using a host's stored credentials, without seeing them
credentials:write medium Save new credentials for the acting user
network:outbound medium Make outbound requests
network:serve medium Open a listening port
network:broadcast medium Send network broadcast packets
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 alerts to users and through their channels
notify:hub high Receive, store and deliver every alert all plugins send
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
files:own low Its own files on disk
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.
  • process:spawn is checked by ctx.process (C3); before that it was only a statement of intent.
  • events:core is the exception that proves the shape: without it a plugin may only emit under plugin.<its id>., because otherwise it could publish host.status and drive the automations engine as though core had.

The ctx surface

Built per plugin in src/backend/plugins/ctx.ts and passed to activate. Every member is listed; the types in @termix/plugin-sdk/backend are the authority for signatures.

Member Capability What it does
pluginId, manifest none The plugin's id and parsed manifest
log.debug/info/warn/error none Lines in core's log, tagged with the plugin
events.emit/on events:core for core topics Topics under plugin.<id>.*, or core's (user.deleted, user.data_wiped, host events)
kv.get/set/delete/list kv:own Small key/value state
files.dataDir() files:own A per-plugin folder, <DATA_DIR>/plugin-data/<id>
db.define/client/refs/persist/dialect db:own (not dialect) The plugin's own tables through Drizzle; persist flushes SQLite to disk
sync.registerEntity none Adds a table to sync between a linked desktop and its server
registry.provide/consume/revoke none The low-level named registry services sit on
services.provide/get/providers per-service RBAC Typed, versioned cross-plugin services, checked per call
secrets.get/set/delete/seal/unseal secrets:own The plugin's own encrypted values
secrets.offer/withdraw/getShared per-secret RBAC Secrets one plugin lends another
http.router(options), http.baseUrl(req) network:serve (router) The router mounted at /plugin-api/<id>/, and the public origin for callback URLs
ws.route/upgrade network:serve Sockets at /plugin-ws/<id>/<path>
rbac.has/hasFor/require own permissions (require) The acting user's role permissions; require is route middleware
settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate hosts:read (listHostValues) The plugin's admin, user and host settings from its manifest
settings.readCore(key) settings:read-core One of the core keys in CORE_SETTINGS_ALLOWLIST
disposables.add none Extra cleanup on deactivate
capabilities.has/require the named capability A generic check for privileged code no ctx member wraps
hosts.list/get/checkAccess hosts:read Hosts the acting user can see, without secrets
hosts.create/update/delete/listOwned/share/listUsers/listRoles hosts:write Host changes and sharing on the user's behalf
hosts.trackSession/recordActivity hosts:read The online indicator and a recent activity entry
hosts.status.get/check/reportLogin/registerPort hosts:read Core's reachability status (get is async since D2)
ssh.connect/withConnection/jumpChain ssh:connect, credentials:use Connections through core's one connect pipeline
ssh.resolveHost/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction ssh:connect, credentials:use The lower level, for a transport with its own prompt flow; resolveHost redacts secrets without credentials:read
ssh.prepare ssh:connect, credentials:use, credentials:read The ssh2 config for a transport that connects its own client; it carries the password and key
ssh.poolKey/dropPooled/requiresSecret/supportsBackground ssh:connect Pool housekeeping and questions for an auth type's provider
auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment auth:provide Login methods and second factors
auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers auth:provide What a login method needs from core's session and provisioning code
auth.registerSshAuthProvider/registerKeyboardInteractiveHandler auth:provide SSH auth types and keyboard-interactive handlers
credentials.listSshKeys / .createSshKey credentials:use / credentials:write The user's saved SSH keys
credentials.resolveHostProtocol credentials:read A host's RDP, VNC or Telnet login
credentials.registerSecretResolver auth:provide Resolves external secret references in host and credential fields
desktop.openIsolatedWindow/launchNativeRdp/available desktop:window (not available) Electron windows and the native RDP client
audit.record none An audit line under the plugin's own action name
schedule.every/after none Timers that stop on deactivate and never overlap
notify.channels/send notify:send Alerts, handed to the hub plugin with the audience resolved in core
notify.serve notify:hub Makes the plugin the one hub that stores and delivers alerts
fetch(url, init) network:outbound Outbound HTTP through core's SSRF guard; allowPrivateHosts hosts also go through the configured proxy
process.run/ensureBinary process:spawn (+ network:outbound to download) Programs on the server
system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer system:tls The server certificate
asUser(userId, fn) none, always audited Background work as a user
currentActor() none The acting user for the current call

B11 added ctx.capabilities.has(capability) / .require(capability), a generic check for a capability no other ctx member wraps. Unlike every other row above, the capability it checks is not fixed at the call site: the plugin names it. That is right for exactly one kind of caller - privileged code core cannot mediate through its own primitives, because it is not core's to mediate. The serial plugin bundling serialport and opening an OS device file is the first: there is no ctx.serial.open() for core to gate, because the whole point is that the plugin talks to the hardware directly. has answers without an audit line, for deciding whether to offer something; require throws PluginCapabilityError and audits like a guarded member. A plugin reaching for this first checks whether its need is actually one of the rows above - device:serial is not a general "trust me" capability, it is specifically for code a dedicated ctx member cannot cover.

ctx.ssh was pulled forward from B so plugin transports go through core's connect pipeline instead of importing ssh2 helpers from core:

  • connect(hostOrId, { purpose, pool? }) returns { client, host, dispose }. host is the host core resolved (ip, port, username, name and the rest), for a plugin that only had a numeric id and needs the details, such as substituting them into a command. B2 added this field.
  • withConnection(host, { pool, purpose }, fn) borrows a pooled connection.
  • jumpChain(jumpHosts, { forHost? }) returns a client at the end of a jump chain, for forwarding to something that is not SSH. host on the result is forHost when given, else a placeholder built from the last hop's id, since a chain has no single resolved host of its own.
  • prepare(host, { client, purpose }), openTransport, classifyKeyboardInteractive and autoResponses are the lower level for a transport with its own prompt flow (B6's interactive file-manager connect with its TOTP and browser sign-in parking flow).
  • B15 showed the higher level covers a multi-request HTTP handshake too: the Docker panel passes connect a prompt channel whose ask answers the waiting request with what to ask (a code, a browser sign-in, credentials) and resolves when the next request (connect-totp, connect-browser-sign-in) brings the answer. A retry TOTP round re-asks instead of failing. docker has no auth code of its own left.
  • requiresSecret(authType) and supportsBackground(authType) ask the provider.
  • purpose is a free label ("terminal", "docker") that auth providers see in connectOptions. D1 moved the keepalive, timeout and environment defaults onto profile: "terminal" (an interactive shell), "session" (a long-lived browsing session such as the file manager or Docker), "stream" (a long transfer or console), "forward" (port forwarding) or "background", the default, for short exec work. Core's own jump chain uses a "jump" profile plugins cannot pick. Core no longer keeps a list of purposes, so it names no plugin.
  • B9 added what an interactive transport needs, for the terminal: resolveHost(hostId, { syncId? }) (the host with secrets, by sync id first, audited), the "terminal" purpose, interactive and hostKeySocket on prepare (so a changed host key can be accepted over the socket), the provider's onBanner and onAuthFailed hooks bound on its result, openTransport(..., { resolveDns, log }), and startInteraction / cancelInteraction for the browser sign-ins (opkssh, step-ca, vault), which the terminal used to reach by import. Providers gained an optional cancelInteraction.
  • B7 added sock to the connect options: an already-open stream to the host, such as a forwardOut channel through another host. connect then skips the transport step (port knocking, proxy, jump hosts, DNS) and runs the host key check and auth over that stream. The tunnels plugin opens the endpoint leg of a source-to-endpoint tunnel this way, so both legs go through the pipeline; before B7 tunnels built their own ssh2 Client and never checked host keys at all.

Each new connection is audited and runs as the current actor; pooled reuse is not. Connections and pool entries are closed on deactivate.

B4 added ctx.hosts, for a plugin that groups or acts across hosts it does not own the way fleets does:

  • list() / get(hostId) return PluginHostSummary (id, userId, name, ip, port, username, tags, folder, authType - no secrets) for the acting user's own and shared hosts. Needs hosts:read.
  • checkAccess(hostId, level) mirrors PermissionManager.canAccessHost, naming the level ("connect" | "view" | "edit" | "manage") rather than a bare yes/no, because a plugin fanning out across a fleet needs to know whether it can only connect, or also manage. Needs hosts:read.
  • share(hostId, targets, permissionLevel, durationHours) grants access the same way the host editor's own share action does, snapshotting shared secrets per target through core's SharedHostSecretsManager. Refuses a host the caller does not hold "manage" on. Needs hosts:write, because granting access to a host is a write on that host even when the plugin owns neither the host nor the grant.
  • listUsers() / listRoles() return minimal { id, username } / { id, name, displayName } lists (roles filtered to non-system) for a share-target picker UI, without exposing the full admin user/role management surface. Needs hosts:write, matching share.

ctx.hosts.get returns the same non-secret projection as list; a plugin that needs to actually connect uses ctx.ssh.connect(hostId) or withConnection(hostId, ...), which resolve the full host (secrets included) through the one connect pipeline. fleets never holds a PluginSshHost itself.

B5 added create, update and listOwned, for a plugin that creates or maintains hosts on the user's behalf rather than only sharing access to existing ones (proxmox's discovery and sync flow is the first caller):

  • create(host) inserts a host owned by the acting user, encrypted the same way the host editor's own create route does. Takes PluginHostCreateInput (name, ip, port, username, authType required; connection flags, tags, folder, jumpHosts and the rest optional) and returns the decrypted PluginHostRecord. Needs hosts:write.
  • update(hostId, patch) updates a host the acting user owns, refusing one it does not (sharing a write onto someone else's host goes through share(), never update()). Returns null for a host that does not exist or is not owned by the caller. Needs hosts:write.
  • listOwned() returns every host the acting user owns as full PluginHostRecords (credentialId, jumpHosts and the rest list()'s narrower PluginHostSummary leaves out), for a plugin scanning its own hosts in the background, such as proxmox's auto-sync sweep. Needs hosts:write, matching create/update: this is the same "full host detail" surface those calls need to read back, not a wider read grant than hosts:read gives through list/get.

B9 added trackSession(hostId) (the online indicator, counted with every other feature's sessions; synchronous, so only the declaration is checked, like ctx.http.router) and recordActivity(hostId, type, hostName) (the same rate limit and access rule as the dashboard's route, which now shares services/recent-activity.ts with it). Both need hosts:read.

PluginHostRecord carries no secret auth material (password, key, vault token): a plugin that creates a host picks an authType and, for "credential", a credentialId it does not need to see the contents of.

B8 added ctx.desktop.openIsolatedWindow({ url, partition?, title?, ignoreCert? }), for a plugin that wants a URL shown outside the main renderer's own session (a tunnelled or direct host web UI, so far). The backend cannot open a BrowserWindow itself: it runs as a separate forked process (electron/main.cjs forks it with stdio: [..., "ipc"]), so the call is relayed over that same fork IPC channel to a small request/response protocol in src/backend/utils/electron-ipc-bridge.ts, and handled in electron/main.cjs by createIsolatedWindows (electron/isolated-window.cjs, the module both the renderer's own isolated-window IPC call and this bridge share). Rejects outside the desktop app (ELECTRON_EMBEDDED unset, or no process.send). Each window gets its own non-persistent session, so it never shares cookies or storage with the main window or with another isolated window.

B14 added ctx.desktop.launchNativeRdp({ host, port, username, domain }) over the same bridge (a second entry in BACKEND_REQUEST_HANDLERS, launch-native-rdp), which opens mstsc on Windows with a generated .rdp file that never carries the password, and ctx.desktop.available(), which says whether the server runs embedded in the desktop app. The renderer's own openNativeRdp IPC call is gone; the desktop app's frontend always asks its embedded backend, which is the one that can reach Electron.

B14 also added ctx.credentials.resolveHostProtocol(hostId, protocol), the first member behind credentials:read. It returns the host's address, jump hosts and the plaintext RDP, VNC or Telnet login the acting user may use, for a plugin that has to hand them to another program (guacd). The logins stay in core next to the host: an owner gets the stored login or the stored credential it points at; a shared recipient gets only core's sharing resolution (the owner's shared snapshot or their own override), never the owner's raw secret; no connect access, or no host, is null. Every call is audited as plugin_credentials_read, allowed or refused.

C6 added two members for Termix Identity, both for the acting user's own saved SSH keys and both audited:

  • listSshKeys() returns { id, name, username, publicKey } for every key credential the actor owns. Core derives the public key from the private key when none was stored, so the plugin never receives private material. Needs credentials:use and the core credentials.view permission.
  • createSshKey({ name, description, username, privateKey, publicKey, keyType }) saves a key pair as a new encrypted credential and returns its id. Needs credentials:write, a new capability, the core credentials.create permission and an unlocked data key. There is no delete: a plugin that has to undo a save orders its own writes so it never needs one (termix-identity publishes the key first and removes that row if the save fails).

C7 added ctx.system, the server certificate, all behind system:tls (critical: whoever holds it decides what certificate every browser is shown) and all audited. Core keeps serving HTTPS: at boot it makes a self-signed certificate only when none exists or its own self-signed one is expiring, and it never replaces a certificate a CA issued, so a certificate keeps being served, unrenewed, with its plugin disabled. The code is in src/backend/tls/.

  • tlsStatus() is { enabled, certificate, renewal }: the served leaf (names, issuer, dates, selfSigned, fingerprint) and the plugin that renews it.
  • writeTlsCertificate(cert, key) validates the pair with node's X509 parser (it parses, the leaf has not expired, the key matches) and swaps both files in by rename. It does not reload.
  • reloadTls() serves what is on disk now: setSecureContext on the direct HTTPS server (starting it if HTTPS was off), or an nginx reload in Docker.
  • publishHttpChallenge(token, content) answers GET /.well-known/acme-challenge/<token>, which core serves publicly because no /plugin-api/ path can be on it. nginx proxies that location to the backend on both the HTTPS server and the port 80 redirect server.
  • registerTlsRenewer() tells core the plugin renews the certificate. Admin settings warn with the expiry date when a CA-issued certificate has no renewer. Both registrations are dropped on deactivate.

Uploading a certificate by hand stays core (POST /users/tls-certificate), since it is not ACME. The acme-ssl plugin is the first caller: it runs acme-client with every request sent through ctx.fetch (an axios adapter), keeps its account key sealed in ctx.kv, and checks every 12 hours.

C3 added ctx.process, for a plugin that ships a program (opkssh):

  • run(file, args, { env, cwd, timeoutMs }) starts it without a shell and returns { pid, onStdout, onStderr, exited, kill }. Output arrives as UTF-8 text. Every program still running is killed on deactivate. Audited as process_run.
  • ensureBinary({ name, version, url, sha256, prebuilt }) returns the path of a verified executable: the first prebuilt path whose SHA-256 matches (a copy baked into the Docker image, used in place), else <DATA_DIR>/plugin-data/<id>/bin/<name> when it matches, else a download over https (redirects followed, network:outbound checked only then), written atomically with mode 755. A mismatch throws and keeps nothing.

B20 added ctx.credentials.registerSecretResolver(scheme, resolve), behind auth:provide like ctx.auth's registrations, for a plugin that expands "<scheme>://..." references in a host's secret fields into the real secret (secret-sources registers "op" for "op://vault/item/field"). The scheme must be listed in contributes.auth.secretSchemes, which exists for the same reason sshAuthTypes does: so core can name the plugin a disabled scheme needs. A core registry in src/backend/hosts/connect/secret-resolver-registry.ts mirrors the SSH auth provider registry one-for-one (one owner per scheme, SecretResolverMissingError naming the plugin when none is registered). resolve is called once per host resolution, for the user the field's owner resolves as, by src/backend/hosts/external-secrets.ts, which is scheme- generic now: it detects a reference by its scheme:// prefix, looks up the registered resolver, and caches the result for a minute. Core registers nothing here itself.

B16 added ctx.hosts.status, the plugin side of core's host status check (see Host status):

  • get(hostId) is core's last result, or null before the first check or for a host with status checks off. check(hostId) checks now unless the last result is under 30 seconds old. host-metrics calls it before the first heavy sample of a host.
  • reportLogin(hostId, { ok, hostKeyChanged? }) tells core a login to the host worked or failed. A working login is what turns "reachable" into "online". Synchronous and not audited, like trackSession: a poller calls it on every sample.
  • registerPort(connectionType, resolve) tells core which port proves a host of that connection type is up, for a protocol whose port is the plugin's own host setting. remote-desktop registers rdp, vnc and telnet; without it core checks the host's port. Removed on deactivate.

B16 also added ctx.ssh.dropPooled(pool, hostId), which closes this plugin's pooled connections to one host (host-metrics calls it on host.updated). It only touches keys this plugin created, so, like deactivate, it never closes another plugin's connections. The core pool is never cleared as a whole by a plugin.

B16 added ctx.schedule: every(ms, fn, { jitterMs?, runNow? }) and after(ms, fn), each returning a function that stops it. Timers live in the plugin's disposable bag, so deactivate clears them; a run that throws is logged, and a repeating job skips a tick while its last run is still going. jitterMs delays the first run by a random amount up to that, so a fleet of per-host timers spreads out. Needs no capability.

ctx.db.persist({ lazy: true }) (B16) marks the database dirty and lets the debounced save write it, for frequent low-value writes such as metrics samples; the default still saves before returning.

B17 added ctx.notify and ctx.fetch:

  • ctx.notify.channels() lists the acting user's notification channels (id, name, type, enabled, never the config), and send delivers an alert. Both need notify:send and are audited (plugin_notify_channels, plugin_notify_send). E1 moved channels, delivery and the inbox out of core into the alerts plugin, gave send one notification argument with an audience, and added serve; see Alerts under "Cross-plugin use".
  • ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts }) goes through safeOutboundFetch: http and https only, no embedded credentials, DNS pinned, no redirects, and private or loopback addresses refused unless the exact host is in allowPrivateHosts. Needs network:outbound, audited as plugin_fetch. Automations' HTTP step passes the admin allowlist it reads through ctx.settings.readCore (notification_private_endpoint_allowlist joined CORE_SETTINGS_ALLOWLIST). C4 added tls: { ca, rejectUnauthorized } for a private CA: ca trusts a PEM bundle, and rejectUnauthorized: false is for the one request that fetches a CA root by fingerprint, which the caller then checks (step-ca, like step ca bootstrap).

B18 added three members for the AI assistant:

  • ctx.secrets.get(key) / .set(key, value) / .delete(key) store a plugin's own secrets for the acting user, next to the offer side of ctx.secrets. Rows live in plugin_settings under the secret scope (scope_id the user id), encrypted with encryptSystemSecret, so they go with the plugin on uninstall and with the user when UserRepository.delete removes that user's rows. The settings service is manifest-driven and never reads them. All three need secrets:own and an actor; set and delete are audited without the value, get only checks the capability, like ctx.db.persist, because a plugin reads its keys on every request that uses them. The AI assistant keeps each provider's key under provider:<id> instead of in its providers table.
  • C1 added ctx.secrets.seal(value) and .unseal(sealed): the same installation-key encryption, returned to the plugin instead of stored, for a secret that lives in the plugin's own table and has to be read without an acting user (a second factor checked during login). Both need secrets:own; neither needs an actor or writes an audit line. unseal answers null for anything seal did not write. The totp plugin keeps its secrets and backup codes this way.
  • ctx.fetch takes a signal. It aborts the request and a streamed body; timeoutMs still only covers the wait for the headers, so a long model stream is not cut.
  • ctx.hosts.delete(hostId) deletes a host the actor owns through core's own delete path (hosts/delete-host.ts, shared with the host editor's route: access grants, activity, plugin settings, the tombstone and the host.deleted event). Needs hosts:write, and the actor needs the core hosts.delete permission. False for a host that is missing or not theirs.

ctx.hosts.create (B14) hands the created row to every plugin's hostImportNormalizer, the same as a bulk import row, so a plugin creating a host can set another plugin's host settings (proxmox marks an imported Windows guest as RDP) without core naming either.

The actor

Never comes from plugin code. It is held in AsyncLocalStorage and set two ways, with no third (a public socket's guest has none):

  • 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.

Two calls do take a user id, for background work that reaches another plugin: ctx.services.get(name, { userId }) (and a handle's asUser(userId)) and ctx.secrets.getShared(..., { userId }). Naming anyone but the current actor there is the same act as ctx.asUser, so since D2 it writes the same plugin_as_user audit line.

ctx.ssh never believes a host object's own userId either. Before D2 a call with no actor ran as whatever userId the plugin put on the host it passed, which reached that user's jump hosts with that user's credentials. Now every ctx.ssh call needs an actor, except for a host core itself resolved earlier (from resolveHost, connect or withConnection), which keeps the user it was resolved for. Any other object host runs as the actor.

D2 also put the per-user access check on hosts.status.*, hosts.trackSession, hosts.recordActivity and ssh.startInteraction: with an actor, a host the actor cannot connect to answers nothing. With no actor they are the plugin's own background work (a poller, a guacd session opening) and only the capability applies.

Credentials

Only a plugin holding credentials:read sees plaintext credential material (HOST_SECRET_FIELDS in ctx-hosts.ts: passwords, keys, passphrases, sudo and SOCKS passwords, the autostart ones and terminalConfig.sudoPassword). Since D2:

  • ctx.hosts always returns hosts with those removed, listOwned included, and listOwned is audited. update never moves id, userId or syncId.
  • ctx.ssh.connect().host is always redacted.
  • ctx.ssh.resolveHost returns the full host, audited as plugin_credentials_read, only to a plugin with credentials:read. Any other plugin gets the redacted host, and core remembers the real one, so handing that object back to connect, withConnection or prepare connects with the real secrets.
  • ctx.ssh.prepare needs credentials:read, because the ssh2 config it returns carries them.

ssh-terminal, file-manager, host-metrics and tailscale declare credentials:read: they run prepare or read a sudo password. credentials.resolveHostProtocol was already behind it.

Refusals

assertCapability writes the refusal's audit line itself, named after the call (plugin_kv_set), so no guard can forget to. A synchronous declaration check writes it in the background through capabilityRefused. Successes are audited for every privileged call except the hot-path reads a plugin makes on every request or sample: kv.get, secrets.get/seal/unseal, db.persist, hosts.list/get/checkAccess, hosts.status.* and hosts.trackSession. src/backend/tests/plugins/security/ctx-guards.test.ts walks the whole ctx object and fails for any member that runs without a capability and is not on its UNGATED list with a reason.

Hooks ssh2 calls synchronously (a provider's onBanner and onAuthFailed, keyboard-interactive detect and autoAnswerPasswords) answer from the grant cache and do nothing until the grant is confirmed.

ctx.process.run gives the program a small base environment (path, home, temp, locale, proxy) plus the plugin's own env, never the server's, which holds the JWT secret and database keys.


Lifecycle rules

  1. Everything is created inside activate. No servers, timers, listeners or connections at module scope. ESM caches modules, so disable-then-enable re-runs activate against the same module object: anything created at module scope is already torn down by the second call and never comes back.
  2. Everything is released on deactivate. Registrations made through ctx are tracked automatically. Anything the plugin creates itself goes in ctx.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.
  3. 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.
  4. A failed activate is still cleaned up, and the plugin's own deactivate() is not called: it never finished starting, so it has no state to tear down.
  5. Plugins never touch process signals or process.exit. Core calls deactivate on shutdown: gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which stops every plugin in reverse activation order. A plugin that installs its own SIGTERM handler and exits skips the rest of that sequence, including other plugins and the database flush.
  6. 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 ctx is 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. Guarding ctx does not change any of that.
  • Protection from malicious code. A plugin that wants to bypass the gate can. Nothing here stops it.

ctx.db is a clear example. It hands back a handle scoped to the plugin's own tables, and the p_<id>_ prefix, the CLI check and the runner's check all say a plugin may only touch its own. Since D2 both checks are one function, findUnownedTableWrites in @termix/plugin-sdk/ddl, which reads each statement the way the engine does: comments and every quoting style are seen through, every name in a DROP TABLE a, b list is checked, a rename target counts as a write, reads (FROM, JOIN, REFERENCES) are allowed, and ATTACH, PRAGMA, GRANT, triggers, functions and other statement kinds are refused outright. The legacy-table exemption applies only to the bundled plugin that adopts the table. removePluginData finds a plugin's tables in the database's own catalog by prefix, leaves a longer prefix another plugin owns, and takes the plugin's settings and secrets with it; role permissions stay. None of that is enforced by the engine: a plugin holding that handle can write any table in the database, and ctx.db.refs hands it users, ssh_data, roles and user_roles outright (the last two added in B2, for a plugin that shares its own rows with a role and needs to resolve a caller's role membership and display names). The prefix, the capability, the audit line, lint and review are the contract. The engine is not.

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.


Threat model

Written in D2, after a security review of the whole plugin system. The short version: a plugin is code you run on your server, with the same reach as Termix itself. Everything below follows from that.

What a malicious plugin can do

A plugin runs in the Termix server process and in the browser tab of anyone who uses Termix. In the server it can do everything the server's OS user can:

  • read DATA_DIR, which holds the database, the system key, the JWT secret and every user's encrypted data, and import core modules to decrypt it;
  • read and write any table, whatever ctx.db says it owns;
  • open sockets, listen on ports, spawn programs and read the environment;
  • replace or patch core modules in memory, including the auth middleware and the capability check itself;
  • log in as any user, because ctx.auth identities and ctx.asUser are trusted by design.

In the browser it runs in the shell's own JavaScript context, so it can read anything the page can, including the session cookie's effects and every other plugin's UI.

None of the checks in this document stop that. They are not a sandbox.

What the system does about it

  • Signing and review. Official plugins live in this repo and ship in the Termix image; they are reviewed like core and trusted by shipping inside it. A plugin outside the image is a .tmxplug signed by a key compiled into Termix (see Packaging and signing), and TERMIX_REQUIRE_SIGNED_PLUGINS=true refuses anything else. Community review and the install UI arrive in 3.0.0.
  • The kill list (3.0.0). A signed list of plugin versions known to be bad, which the server refuses to load, so a bad plugin can be switched off on every install without waiting for each admin.
  • Capability consent. A manifest names every privileged thing its code does through ctx, with a risk level. In 3.0.0 an admin grants each one when installing; in 2.9.0 bundled plugins get what they declare.
  • Audit. Every privileged ctx call, allowed or refused, writes a line with the plugin id and the acting user, and so does every switch of the actor (ctx.asUser, or a service handle or shared secret named for another user). "What did this plugin do, as whom" has an answer.
  • Honest plugins stay honest. The checks D2 added or confirmed stop the accidental and the confused-deputy cases, which are the ones that actually happen:
    • every privileged member is guarded, and a test walks ctx so a new member without a guard fails CI;
    • plaintext credentials only reach a plugin holding credentials:read;
    • a host object's userId is never believed, so a plugin cannot borrow another user's jump hosts or credentials by naming them;
    • ctx.hosts and ctx.ssh apply the actor's RBAC to every host;
    • routes need login unless declared public, public routes are listed in the admin plugin dialog, a disabled plugin answers 503, errors hide their stack, and an anonymous caller cannot spend a plugin's error budget;
    • /plugin-assets serves only a running plugin's browser files, with no way out of the plugin folder, symlinks included;
    • a user plugin cannot take a bundled plugin's id, a reserved id (admin, hosts, credentials and a few more) or an id whose table prefix overlaps another plugin's;
    • migrations are parsed, not pattern-matched, and may only write the plugin's own tables;
    • a plugin's programs get a small environment without the server's secrets.

Before installing a community plugin

  • Know where it came from. Install from a source you would trust with root on the server. Prefer a signed .tmxplug and run with TERMIX_REQUIRE_SIGNED_PLUGINS=true.
  • Read the capabilities. Anything critical or high (credentials:read, system:tls, ssh:connect, process:spawn, users:write, auth:provide, device:serial) means the plugin can reach your hosts, your users or the server itself. Ask why it needs each one.
  • Read the public routes. publicRoutes on GET /plugins lists every path it serves without login. Each one is attack surface on your server.
  • Check its dependencies. A plugin's npm dependencies run with the same reach as the plugin.
  • Watch the audit log after installing. Filter on plugin:<id> and look for calls you did not expect, especially plugin_as_user and plugin_credentials_read.
  • Run Termix as an unprivileged user, in its container or under its own OS account, so "everything the server user can do" is as little as possible.

Building a plugin

termix-plugin, the CLI the SDK ships, is the only build a plugin needs. It runs from the plugin's own directory, so a plugin that moves to its own repo keeps working unchanged.

Command What it does
termix-plugin build esbuild bundles src/backend/index.ts to dist/backend.js (ESM, node22) and src/frontend/index.tsx to dist/frontend.js (ESM, browser), then copies locales/ and migrations/ into dist/. Imported CSS lands in dist/frontend.css.
termix-plugin validate Runs the SDK's parseManifest, checks the files the manifest names exist, and checks that migrations only touch this plugin's tables and exist for every dialect.
termix-plugin migrations Diffs src/backend/tables.ts against migrations/snapshot.json and writes one .sql per dialect. --check fails when a definition changed without a migration. A rename or a type change is refused rather than guessed at, because that is a data decision.
termix-plugin test Runs the plugin's vitest suite.
termix-plugin pack Validates, then writes <id>-<version>.tmxplug (see Packaging and signing). --out <dir> picks the folder.
termix-plugin sign Signs a .tmxplug with the key in TERMIX_PLUGIN_SIGNING_KEY and writes <file>.tmxplug.sig.
termix-plugin verify Checks a .tmxplug against its .sig and --key <base64>[,<base64>].
termix-plugin keygen Writes a new Ed25519 private key to termix-plugin-signing.key and prints the public key and key id. Never prints the private key.

npm run build:plugins stages every plugin listed in docker/bundled-plugins.json in dist/plugins/<id>/, which is what getBundledPluginsDir() resolves to and what Docker and electron-builder package. npm run build, build:backend and dev:backend all call it.

TypeScript only type-checks (tsconfig.plugins.backend.json and tsconfig.plugins.frontend.json, noEmit); esbuild does every emit. Nothing is written next to a source file.

Packaging and signing

The .tmxplug file. A gzipped tar of manifest.json, dist/, locales/, migrations/, README.md, CHANGELOG.md and icon.svg (whichever exist). pack refuses when validate finds an error. The archive is reproducible: entries sorted by path, files only, mode 0644, owner 0, mtime 0, and a gzip header with no timestamp and a fixed OS byte. The same tag rebuilt on any machine gives the same sha256, as long as the build output is the same (check out with the same line endings).

Signatures. termix-plugin sign makes an Ed25519 signature over the raw 32-byte sha256 of the .tmxplug and writes it, base64, to <file>.tmxplug.sig. The private key comes from TERMIX_PLUGIN_SIGNING_KEY (base64 PKCS8 as keygen writes it, or PEM); in CI it is a GitHub Actions secret and it is never committed. A registry index carries the same base64 string in each version's signature.

Pinned trust. src/backend/plugins/trust.ts holds the registry public keys as compile-time constants (TRUSTED_PLUGIN_KEYS) and verifyPluginArtifact(buffer, sha256, signature), which checks the hash and then the signature against each pinned key. A key read from a registry index, a download or the data directory is never trusted: whoever serves the index could sign anything with it. Rotation needs a Termix release: add the new key, ship, sign with it, and keep the old key for one release cycle so late upgraders still accept plugins signed before the switch. The steps are in the Termix-Registry README.

What the server loads. Bundled plugins (dist/plugins/) are trusted by shipping in the image and are not checked. In the data directory, <DATA_DIR>/plugins/ holds either a plugin folder (as before) or a <name>.tmxplug with an optional <name>.tmxplug.sig next to it. A .tmxplug is unpacked fresh on every load into <DATA_DIR>/plugins/.unpacked/<id>/ (plain files only, no path outside it). A .sig that does not verify always blocks the plugin. With TERMIX_REQUIRE_SIGNED_PLUGINS=true, a plugin folder is blocked (it carries no signature) and so is a .tmxplug without a valid .sig. A blocked plugin is logged and skipped like any other plugin that fails to load.

Publishing the SDK. Plugins outside this repo depend on @termix/plugin-sdk from npm. It is published from this repo with npm run build:sdk && npm publish --workspace @termix/plugin-sdk; the package's files list ships dist/ with its .d.ts files, the CLI, the manifest schema and tsconfig.plugin.json. Outside a Termix checkout the vitest preset does not alias the shell's modules, so renderWithApp and @termix/plugin-sdk/ui are only available to plugins in this repo for now.

Host-provided packages

These ship with the Termix server and are never bundled into a plugin, because a second copy of express or React is a bug, and a second ssh2 means a second set of native bindings. A plugin may depend on anything outside these lists, and that is bundled into its output.

Backend (plus every node builtin): @termix/plugin-sdk, express, ssh2, ws, multer, cookie-parser, axios, jszip, guacamole-lite, @anthropic-ai/sdk, drizzle-orm, sharp (native, so it cannot be bundled; the terminal's image upload loads it on first use).

Frontend: react, react-dom, react-dom/client, react/jsx-runtime, i18next, react-i18next, sonner, @termix/plugin-sdk/frontend and @termix/plugin-sdk/ui, all resolved through the import map (see UI). Everything else, including lucide-react, axios, cytoscape and guacamole-common-js, is bundled into the plugin that uses it.

The lists live in packages/plugin-sdk/cli/lib/externals.mjs.

Native dependencies

A package with a compiled .node binding (serialport, better-sqlite3, node-pty) cannot be bundled the way an ordinary npm dependency is. esbuild would inline its JS, but the binding is loaded through a path computed relative to the original package's own directory, which breaks once that JS moves into dist/backend.js somewhere else entirely. It also cannot go on the host-provided list above: that list is for packages every plugin shares one instance of, and a serial port binding is one plugin's business, not core's.

B11 (the serial plugin, the first bundled plugin with a native dependency) resolved this by declaring the package in the manifest's nativeDependencies array (["serialport"]) as well as a real dependency in the plugin's own package.json. termix-plugin build adds every entry to esbuild's external list for that plugin alone, so the compiled bundle keeps a plain import { SerialPort } from "serialport" instead of inlining it, and termix-plugin validate checks the name is actually declared in the plugin's own dependencies.

At runtime that import is a bare specifier Node resolves by walking up from wherever dist/backend.js is loaded, through dist/plugins/serial/, dist/plugins/, dist/, to the repo (or image) root. The three deployments this repo builds all end up with the package there because of how npm workspaces already install it, not because of anything specific to native code:

  • Dev and CI. npm install at the repo root installs every workspace's own dependencies, including a plugin's, and hoists them into the root node_modules unless a version conflict forces one to stay nested. Nothing about serialport is special here; it lands exactly where any of this plugin's other dependencies would.
  • Docker. The image's npm ci runs once at the repo root over the whole workspace and produces one node_modules, which is copied into the final image wholesale (docker/Dockerfile's production-deps stage). Its native binding compiles against the image's own node:26-slim toolchain (python3 make g++, already installed for better-sqlite3), once per target architecture under docker buildx, so linux x64 and arm64 each get a binary actually built for that machine rather than a cross-compiled one.
  • Electron. electron-builder.json's asarUnpack already unpacks all of node_modules (not a curated subset), and npm run electron:rebuild runs electron-rebuild over the same hoisted node_modules, rebuilding @serialport/bindings-cpp against Electron's ABI by name, exactly as it already did for better-sqlite3 and node-pty. Nothing in that script changed: a package's native binding gets rebuilt because the script names it, not because of which package.json declared the package as a dependency.

None of this required a new packaging mechanism, which is the honest reason it was chosen over shipping the plugin its own node_modules: this repo's build already treats the whole workspace as one node_modules, so a native dependency declared in a plugin's package.json rides along for free. A community plugin installed from a tarball has none of this: there is no repo-root npm install to hoist its dependency into, no shared node_modules its bundle can walk up to. Shipping a native dependency in a plugin nobody's build system already vendors is an open problem this step does not solve, and it waits for the split into separate repositories. A community plugin author who hits this today has three honest options, worst to best: ask the person installing it to npm install the native package into the server's own node_modules by hand (fragile, easy to get wrong on upgrade); avoid a native dependency and reach for a WASM build of the same functionality if one exists; or wait for prebuild-per-platform packaging support, which is not built yet.


Converting a feature into a plugin

The checklist every Phase B step follows. A9 ran it on workspaces, so plugins/workspaces/ is the worked example for each step: read its source and tests next to this list. Steps are ordered so data is never dropped before it has moved. Commands run from the repo root unless they start with cd.

A conversion is done when the plugin imports nothing from src/ or @/, core imports nothing of the feature, every piece of existing data survives an upgrade on all three engines, and the checks in step 15 are green.

1. Scaffold

  1. Create plugins/<id>/ with the layout in Where plugins live. The directory name is the manifest id. If A2 already moved the feature there, keep the directory and work through the rest in place.
  2. package.json is @termix-plugin/<id>, private, with the four scripts (build, test, typecheck, validate) calling termix-plugin, and @termix/plugin-sdk as a dev dependency. vitest.config.ts is the one-line preset.
  3. tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json and has no paths into ../../src. A path alias is how core imports sneak back.
  4. Run npm install so the workspace is linked.

2. Manifest and capabilities

  1. Fill in the manifest (see the reference). Declare every view the frontend registers in contributes.panels, tabs or dashboardCards, or app refuses to register it.

  2. Pick the smallest set of capabilities from the catalog. Choose by the ctx members the code calls, not by what the feature feels like:

    The code calls Declare
    ctx.db.* db:own
    ctx.kv.* kv:own
    ctx.http.router, ctx.ws.* network:serve
    ctx.ssh.* ssh:connect, credentials:use
    ctx.auth.* auth:provide
    ctx.process.* process:spawn
    ctx.settings.readCore settings:read-core
    ctx.events.emit outside plugin.<id>. events:core
    anything registered through app ui:surface

    Workspaces needs db:own, network:serve and ui:surface, nothing else.

  3. cd plugins/<id> && npx termix-plugin validate must print ok.

3. Backend: replace every core import with the SDK

  1. List what the backend reaches in core: grep -rn "src/backend\|src/types" plugins/<id>/src. Each hit gets replaced. A plugin never imports ../../../../src/....

  2. The usual replacements:

    Core import SDK replacement
    AuthManager middleware, req.userId nothing: core authenticates ctx.http routes; read the user with ctx.currentActor()
    requirePermission(...) ctx.rbac.require("<short name>")
    databaseLogger, *Logger ctx.log
    createCurrent*Repository() and schema.ts tables the plugin's own table through ctx.db (step 4)
    insertReturning / .returning() insert, then select by a key you set (a sync id)
    DatabaseSaveTrigger, a repository write hook await ctx.db.persist() after every write
    ssh2 connection code, the pool, resolveHostById ctx.ssh.connect / withConnection / jumpChain
    getCurrentSettingValue ctx.settings.get for its own keys (step 5), readCore for core's
    another plugin's source ctx.services.get(...) (step 10)
  3. Everything the backend creates lives inside activate(ctx): the table (ctx.db.define), the repository, the router, timers (ctx.disposables.add). Nothing at module scope. deactivate is usually empty.

  4. When the SDK does not have it, add it to the SDK. Never work around a gap with a core import. A new ctx member is:

    1. the type and a doc comment in packages/plugin-sdk/src/backend.ts;
    2. the implementation in src/backend/plugins/ctx.ts, wrapped in guarded() when it is privileged, so it checks the capability and writes an audit line; a new capability is one entry in src/capabilities.ts plus its i18n title and consequence under plugins.capabilities.<id> in src/ui/locales/en.json;
    3. the same member in createFakeContext and a gated one in createMockCtx (packages/plugin-sdk/src/testing.ts);
    4. a core test in src/backend/tests/plugins/ (allowed and refused);
    5. a row in The ctx surface and a line in the right section of this document.

    Then npm run build:sdk, because core and the CLI read the SDK's dist. A9 added ctx.db.persist, ctx.db.dialect, sync shouldSync, createTestDb, app.t, app.hasPermission and the rail permission this way.

4. Tables: adopt them, then remove them from core

  1. Write src/backend/tables.ts. Wrap each definition that replaces a core table in adoptLegacyTable("<legacy name>", defineTable(...)) and export tables. Keep the legacy column names (camelCase properties become snake_case columns) and the legacy index names. The table must be listed under this plugin in LEGACY_TABLE_OWNERS (packages/plugin-sdk/src/db.ts). Use refUser() / refHost() for owner columns: they cascade on delete.
  2. cd plugins/<id> && npx termix-plugin migrations adopt_<table> writes migrations/{sqlite,postgres,mysql}/0001_adopt_<table>.sql and migrations/snapshot.json. Read all three files. Never edit one after it has shipped; add a new migration instead.
  3. Remove the table from core, in this order:
    • src/backend/database/db/schema.ts: the table block.
    • src/backend/database/db/index.ts: the CREATE TABLE bootstrap block and any ALTER for it. Some of these files use CRLF line endings; keep them.
    • src/backend/database/db/performance-indexes.ts: its entries. The adopted definition's indexes replace them.
    • src/backend/database/repositories/factory.ts and the repository file.
    • src/backend/database/routes/delete-user-data.ts and the user/host repositories: explicit deletes of the table. A refUser/refHost column cascades on every engine, because the foreign key survives the rename. Prefer that cascade. A table that cannot carry such a column (evidence that has to outlive the account, like a recording) subscribes to ctx.events.on("user.deleted", ...) or "host.deleted" instead, added to the SDK the step 3 way; B13 added user.deleted (TOPICS.userDeleted in src/backend/plugins/events.ts), emitted from deleteUserAndRelatedData right where the old direct repository call used to sit. host.deleted already existed (B7).
    • src/backend/sync/entities.ts, if core synced it (step 9).
    • Anything else grep -rn "<table_name>\|<tableConst>" src finds, including core tests and src/types.
  4. npm run schema:generate (the pg and mysql schema variants), then npm run schema:migrations. drizzle-kit writes a DROP TABLE for each removed table in drizzle/{sqlite,postgres,mysql}/. Replace every one of those statements with a comment and SELECT 1; (MySQL refuses an empty query) and keep the new meta snapshots. The drop would run at boot, before the plugin's adoption, and delete every row.
  5. Why the order is safe: core boots first, runs its drizzle migrations (the legacy table is still there, untouched), then the plugin's migration renames it. A plugin that is disabled at upgrade time simply leaves the legacy table alone until it is enabled.
  6. Tests (step 13) prove the adoption with the legacy table present and absent.

5. Global settings keys become plugin settings

  1. Declare each key in contributes.settings.admin (install-wide) or .user, keeping the key name the frontend already sends where possible, and give it a default. Secrets are type: "secret".
  2. Write a boot migration in src/backend/utils/crypto-migration/, modelled on tailscale-settings-migration.ts: read the old row from settings (or the per-user source), write it to plugin_settings for the plugin only when no row exists there yet, then leave the old row in place for one release. Call it from the boot sequence in starter.ts after the plugins table exists.
  3. Test it in src/backend/tests/utils/: values move, secrets stay encrypted, and running it twice changes nothing.
  4. Switch the plugin to ctx.settings.get / getUser, delete the core settings route and UI for those keys, and remove the keys from CORE_SETTINGS_ALLOWLIST if they were there.

6. ssh_data host columns become host-scope settings

Follow Moving a host column into a plugin exactly: declare contributes.settings.host, copy with an idempotent migration tested by running it twice, switch the plugin to ctx.settings.getHost and delete the hardcoded editor tab, and only then drop the column in lockstep across schema.ts, db/index.ts, schema:generate and a drizzle migration per dialect. Copy and drop can ship in different steps; a column core still reads is not ready to drop.

7. Permissions

  1. Declare contributes.permissions with titleKey and descriptionKey in the plugin's own locales. Keep existing ids: core registers <id>.<name>, so pick the short name that makes the id equal the old core one (ai + use is ai.use) and no role needs migrating.
  2. Remove those entries from PERMISSION_CATALOG and SYSTEM_ROLE_DEFAULTS in src/backend/utils/permission-catalog.ts, and move their i18n.
  3. defaultRoles are admin and/or user, applied once.
  4. Gate every route. router.use(ctx.rbac.require("<name>")) before the routes is the simplest way to make sure none is missed; workspaces declares use and gates the whole router on it.
  5. Gate the frontend too: permission on the rail item, usePermission in components, await app.hasPermission(...) before background calls (the workspaces autosave), so a user without it collects no 403s.

8. HTTP, WebSockets and nginx

  1. Routes go on ctx.http.router() (served at /plugin-api/<id>/) and sockets on ctx.ws.route / ctx.ws.upgrade (/plugin-ws/<id>/<path>). Route paths are relative to the mount point.
  2. Update every route's OpenAPI JSDoc path to /plugin-api/<id>/....
  3. Delete the feature's own port and server from core: its import in src/backend/starter.ts, its listen, and its location blocks in both docker/nginx.conf and docker/nginx-https.conf. /plugin-api/ and /plugin-ws/ are already proxied.
  4. Point the frontend at the plugin client (step 11); old paths such as /workspaces stop existing.

9. Sync entities

If core synced the data, or it is personal configuration that should follow a user between a linked desktop and its server, register it in activate: ctx.sync.registerEntity({ type, table, order, ... }) and list the wire name under contributes.syncEntities. Keep an existing wire name exactly and remove core's registration from src/backend/sync/entities.ts. Give an entity that references others a higher order than they have. Use shouldSync for rows that belong to one install (workspaces leaves out its last_session row). Deletes need no extra call.

10. Cross-plugin and core callers

  1. grep -rn the old repository or module across src/ and plugins/. A core caller means the feature is not fully out yet: move the logic behind a core extension point or into the plugin.
  2. Another plugin calling it gets a service: declare it in provides (<id>.<noun>, a version and a permission), ctx.services.provide it in activate, and have the caller declare an optional requires entry plus optionalDependencies. The caller must work when it is absent (catch and degrade). Workspaces provides workspaces.saved to the AI plugin.

11. Frontend

  1. List core imports: grep -rn "from \"@/\|src/ui" plugins/<id>/src/frontend. Replace each:

    Core import SDK replacement
    @/components/* @termix/plugin-sdk/ui
    authApi, handleApiError from @/main-axios app.api or usePluginApi(), paths relative to the plugin
    react-i18next, i18next.t useTranslation() in components, app.t elsewhere
    usePermissions usePermission / app.hasPermission
    shell modules (workspaceUtils, tab state) app.tabs, useTabs
    @/types/* the plugin's own types.ts
    small helpers (getErrorMessage) a few lines in the plugin

    Something the plugin genuinely needs from the shell's component library goes into src/ui/plugin-host/sdk-ui.ts (a contract change: list it under The ui entry).

  2. Register everything in activate(app): rail item (with permission when there is one), panel, tabs, host editor sections, actions and slot contributions. Use app.onDispose for timers.

  3. Remove core's references: imports from the feature, hardcoded ids and switch cases. Where core still needs the feature, name a slot, action or component id in core's own vocabulary and let the plugin fill it. node scripts/check-core-plugin-ids.cjs must pass.

  4. The rail item appears in Appearance > Sidebar > Navigation on its own because it is registered; confirm it there.

  5. No hardcoded text (a relative time is a translated string), no em dashes, square containers.

12. Locales

  1. Move every key the plugin uses from src/ui/locales/en.json into plugins/<id>/locales/en.json, keeping the key paths so the code changes only its namespace. Keys still used by core stay in core; a plugin may keep using core's common.*, which is the fallback.
  2. Move the same keys, per language, from src/ui/locales/translated/<xx_YY>.json into plugins/<id>/locales/translated/<xx_YY>.json, so no translation is lost. Crowdin already picks up plugins/*/locales/en.json (crowdin.yml).
  3. Manifest keys (titleKey, labelKey) are plugin-relative.
  4. Only en.json is edited by hand afterwards.

13. Tests

  1. Move the feature's tests to plugins/<id>/tests/backend and tests/frontend. No plugin test imports src/, and no core test imports plugins/. Delete core tests of code that left core.
  2. The minimum a conversion ships:
    • routes against a real database: createTestDb(pluginDir) + createMockCtx({ db, router: () => express.Router(), permissions }) + activate, served on an ephemeral port and called with fetch (plugins/workspaces/tests/backend/helpers.ts);
    • permission denial on every route;
    • the adoption with the legacy table present (rows and indexes survive) and absent (fresh table), using createTestDb's before hook with the legacy DDL copied from db/index.ts;
    • activate failing closed without each capability;
    • a frontend activate.test.tsx on renderWithApp, plus the panel or tab rendering against a stubbed api.
  3. Every settings or host-column migration gets its run-twice test in core.
  4. cd plugins/<id> && npx vitest run while working; npm run test:plugins at the end.

14. Build, test and check by hand

npm run build:sdk
(cd plugins/<id> && npx termix-plugin validate && npx termix-plugin migrations --check)
npm run build:backend
npm run type-check
npm run lint
npm run test
npm run test:plugins

Then, with the app running:

  1. Boot on an existing SQLite database from before the conversion: the data is there, the p_<id>_ tables exist and the legacy tables are gone.
  2. Use every feature path once: create, edit, delete, anything background.
  3. Remove the plugin's permission from a role: the rail item disappears and the API answers 403.
  4. Disable, enable and disable the plugin in admin without a restart: its views show the "needs the plugin" placeholder while off and come back after.
  5. On Postgres or MySQL if available: npm run verify:dialect, then boot.
  6. Update this document for anything the step added to the contract.