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

84 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/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 the exceptions recorded under legacy core imports.

The SDK holds the contract: types, the catalog, and pure helpers. The ctx implementation lives in core (src/backend/plugins/ctx.ts) and is injected at activation, so a plugin cannot reach around it.

3. Capabilities are what a plugin's code may do

Declared in the manifest's capabilities array using domain:action names from one catalog in the SDK. Every privileged SDK call checks the capability and writes an audit line naming the plugin and the acting user.

Two things must both be true, and a grant for something the manifest never declared is ignored rather than honoured:

  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, snippets, 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. 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.

Sync entities are registered, not hardcoded. ctx.sync.registerEntity adds an entity to remote sync; core registers its own ten the same way in database/routes/sync-entities.ts. The wire names are unchanged, because existing tombstones and rows on the far side match on those strings. The Electron client asks the server for the ordered list and falls back to its frozen array for a server that predates the endpoint.

shouldSync(row) leaves rows out in both directions: they are not pulled, and a push, update or tombstone aimed at one is refused with 400. It exists for state that belongs to one install, such as the workspaces plugin's "Last Session" row, which would otherwise leave a user with one from each side.

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. It matches the full path, with :param segments allowed, so declaring /webhook/:token public cannot open /webhook/:token/anything.
  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.
  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.

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 (per-message auth), and the Guacamole display (a single-use encrypted connection token in the query, minted by an authenticated route).

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.

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

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-shell-plugin-ids.cjs (run by lint) fails on a quoted plugin id in src/ui outside tests, apart from the entries in scripts/shell-plugin-id-allowlist.json, each with a reason (host protocol data like rdp, which Phase B moves).

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.

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, @termix/plugin-sdk/ui and @termix/legacy-core/* 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, which includes the legacy-core modules plugins import.

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
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
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 and a second factor challenge (plus an optional enrollment section)
api, wsUrl(path) axios on /plugin-api/<id>/ and the plugin's WebSocket URL
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
guest, info, onDispose Guest mode flag, plugin info, extra cleanup

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). The SDK has no runtime dependencies: the hooks delegate to a host bridge core installs.

Slots core owns: terminal.toolbar, terminal.dock, terminal.overlay (declared by ssh-terminal), onboarding.steps, onboarding.features, onboarding.workflow, hosts.importMenu, hosts.panel, proxmox.hostEditor and session.remoteDisplay. Cross-plugin frontend calls go through actions: automations.list, fleets.list, session.remoteDisplay.token.

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, select, select2, separator, switch, textarea, tooltip, section-card, metric-card, charts, the card grid, ConnectionScreen and the connection status helpers, SnippetVariablesDialog, FullScreenAppWrapper, the connection log context, TOTPDialog, SSHAuthDialog, WarpgateDialog, useTabs/useTabsSafe, ActionSlot, ComponentSlot and FOLDER_COLORS (the colour swatches folders and workspaces pick from). Publishing its .d.ts for plugins outside this repo is a follow-up for the repo split.

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

tsconfig.plugins.frontend.json checks every plugin frontend and its tests with bundler resolution, and is part of npm run type-check.

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.

10. Lifecycle

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

11. Tests

Each plugin has plugins/<id>/tests/backend (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.

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 and ssh_data 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. 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 returns setActor(userId) for a test middleware and services for what the plugin provided. plugins/workspaces/tests/backend/helpers.ts is the worked example.

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

In 2.9.0, OIDC (and GitHub, Google), LDAP, passkeys, TOTP, OPKSSH, Step-CA, Vault, Tailscale and Warpgate still live in core. They register through the same interfaces from src/backend/auth/legacy-providers.ts and src/ui/auth/legacy-auth-ui.tsx with pluginId: "core". Phase C moves each one into its plugin by moving its block out of those two files; D1 deletes them. Nothing else in core branches on those type names.

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 purpose (terminal, file-manager, tmux, metrics, fleet, docker, ...), 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): an interceptor first (Warpgate), 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
  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.

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 must send the browser back to /users/auth/<methodId>/callback. The older routes (/users/login, /users/totp/verify-login, /users/oidc/*, /users/ldap/login, /users/webauthn/authenticate/verify) are thin wrappers over the same pipeline.

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. 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
  dist/                    build output, gitignored

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.


Manifest v2 reference

plugins/<id>/manifest.json. The directory name must equal id. Unknown fields are rejected at every level: a typo'd contribute used to validate clean and be silently dropped, which made a manifest describe something it did not do.

{
  "id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
  "name": "Example",
  "version": "1.0.0", // semver
  "description": "What it does.",
  "author": { "name": "You", "url": "https://..." },
  "license": "MIT",
  "repository": "https://...", // optional
  "category": "Productivity", // see the enum below
  "icon": "Sparkles", // optional: Lucide name or a bundled icon.svg

  "engine": {
    "termix": ">=2.9.0", // display only, never enforced
    "api": "1", // the real compatibility gate
  },

  "capabilities": ["kv:own", "ui:surface"],

  "dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation
  "optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal

  "provides": [
    {
      "service": "example.thing",
      "version": "1.0.0",
      "permission": "example.use",
    },
  ],
  "requires": [
    { "service": "other.thing", "versionRange": "^1.0.0", "optional": true },
  ],
  "providesSecret": [
    { "key": "api-key", "permission": "example.secrets.share" },
  ],
  "requiresSecret": [
    { "plugin": "other-plugin", "key": "api-key", "optional": true },
  ],

  "contributes": {
    "tabs": [
      {
        "id": "example",
        "titleKey": "nav.example",
        "icon": "Sparkles",
        "openFrom": ["rail"],
      },
    ],
    "actions": [
      {
        "id": "example.open",
        "titleKey": "k",
        "handler": "open",
        "permission": "example.use",
      },
    ],
    "actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
    "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"],
    },
    "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": {
        "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
}

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

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
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
system:tls high Request and replace the server certificate
hosts:write medium Create and change hosts
credentials:use medium Connect using a host's stored credentials, without seeing them
network:outbound medium Make outbound requests
network:serve medium Open a listening port
users:read medium See usernames and roles, never hashes or 2FA state
events:core medium Subscribe to and emit core events carrying other users' data
notify:send medium Send notifications through configured channels
audit:read medium Read the audit log
desktop:window medium Open desktop windows (Electron only)
hosts:read low See the host list it can already see
db:own low Own tables
kv:own low Small key/value state
secrets:own low Its own encrypted secrets
settings:read-core low Read core server settings
ui:surface low Contribute tabs, panels and settings

Rules

  • One catalog, in packages/plugin-sdk/src/capabilities.ts. Nothing else defines a capability.
  • Adding one is an entry here plus the SDK method that checks it. A capability nothing checks is decoration, and v1 shipped nineteen of those.
  • A capability is checked where the privileged thing happens, not at activation. Activation-time checks say what a plugin might do; call-time checks say what it did.
  • events: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.

Member Capability Status
ctx.pluginId, ctx.manifest none A1
ctx.log.* none A1
ctx.events.emit / .on events:core for core topics A1
ctx.kv.get/set/delete/list kv:own A1
ctx.registry.* none A1
ctx.services.provide / .get per-service RBAC A1
ctx.secrets.offer / .withdraw / .getShared per-secret RBAC A1
ctx.disposables.add none A1
ctx.asUser(userId, fn) none, always audited A1
ctx.currentActor() none A1
ctx.db.define / .client / .refs db:own A3
ctx.db.persist / .dialect db:own (persist only) A9
ctx.sync.registerEntity none A3
ctx.http.router / ctx.ws.route / .upgrade network:serve A4
ctx.rbac.has / .hasFor / .require own permissions only A5
ctx.hosts.* hosts:read / hosts:write B
ctx.ssh.* ssh:connect, credentials:use A8
ctx.settings.* settings:read-core (readCore only) A6
ctx.notify.* notify:send A6
ctx.auth.* auth:provide A8
ctx.fetch network:outbound B

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, dispose }.
  • 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.
  • prepare(host, { client, purpose }), openTransport, classifyKeyboardInteractive and autoResponses are the lower level for a transport with its own prompt flow (docker's console, host metrics).
  • requiresSecret(authType) and supportsBackground(authType) ask the provider.

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

The actor

Never comes from plugin code. It is held in AsyncLocalStorage and set two ways, with no third:

  • A request. A4's HTTP and WebSocket middleware runs the handler as the user core already authenticated.
  • ctx.asUser(userId, fn) for background work with no request behind it. Always audited, and every core API inside still applies that user's RBAC.

A plugin cannot pass a user id to a guarded method and have it believed. This is what closes the forged-caller bug the old worker broker had, where a worker could set callerUserId on its own messages.


Lifecycle rules

  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. 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 and ssh_data outright. 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.


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 Writes a .tgz of the manifest, dist/, locales, migrations and README. D3 adds signing.

npm run build:plugins builds every plugin and stages the result 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.json, noEmit); esbuild does every emit. Nothing is written next to a source file.

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.

Frontend: react, react-dom, react-dom/client, react/jsx-runtime, i18next, react-i18next, sonner, @termix/plugin-sdk/frontend, @termix/plugin-sdk/ui and @termix/legacy-core/*, 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.


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.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, and never add to src/backend/auth/legacy-providers.ts. 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 needs a core "user deleted" or "host deleted" event on ctx.events, added to the SDK the step 3 way; none exists yet because no plugin needed one.
    • 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 the desktop app and a server, register it in activate: ctx.sync.registerEntity({ type, table, order, ... }). Keep an existing wire name exactly and remove core's registration from sync-entities.ts. Leave electron/remote-sync-entities.cjs alone: it is the fallback for servers older than the entity-types endpoint, which still sync the core entity. 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).

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 the shell's references: imports from the feature, hardcoded ids and switch cases, and the matching entries in scripts/shell-plugin-id-allowlist.json. node scripts/check-shell-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. Boundary allowlist

node scripts/check-plugin-boundaries.cjs --write, then git diff scripts/plugin-boundary-allowlist.json: the plugin's entries are gone and no other line was added. Update the counts in the table under Legacy core imports.

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

Legacy core imports: the debt D1 removes

The bundled plugins predate the SDK, apart from workspaces, which A9 rebuilt as the reference conversion and which imports nothing from core. They still reach core by relative path (../../../../src/backend/...), which an esbuild plugin, packages/plugin-sdk/cli/lib/legacy-core-imports.mjs, keeps out of the bundle and rewrites to the compiled output path (../../../backend/backend/..., or ../../../backend/types/... for shared types). The rewrite is output-relative, so a source file at any nesting collapses to the same prefix. D1 deletes that file.

Frontends do the same with @/: in the browser build, @/x and ../src/ui/x become the bare @termix/legacy-core/ui/x (or @termix/legacy-core/types/x), which core's Vite build exposes as a shared module so plugin and shell use one instance of each core module. The CLI refuses the rewrite outside a Termix checkout. Every such import is counted in the plugin-frontend-to-core direction below.

This is expected and temporary. The SDK does not yet expose what they need: the SSH connection pool, host resolution, the repository layer, the shared component library. D1 removes the debt once A3 through A8 have given them supported APIs to move onto.

What the lint fence enforces today, in eslint.config.mjs:

Direction Severity Offenders Emptied by
Core importing a plugin backend Error 0 -
A plugin backend importing frontend code or @/ Error 0 -
The shell importing plugin code Error 0 -
A plugin frontend importing core through @/ Warning 105 files D1
A plugin importing core by relative path Warning 67 files D1
A plugin importing another plugin's source Warning 3 files B18

A warning does not fail a build, so the counts are held by scripts/check-plugin-boundaries.cjs, run by npm run lint. Every offender is listed in scripts/plugin-boundary-allowlist.json: a new one fails, and so does an entry that stopped being an offender, so the list shrinks as each step lands rather than rotting. --write regenerates it. D1 empties it and the two plugin-side warnings become errors.

The two plugin-backend directions use different rule names (no-restricted-imports and @typescript-eslint/no-restricted-imports) because flat config replaces a rule's options rather than merging them, so one rule cannot carry both severities on the same files.

Known specifics:

  • TODO(B18): plugins/ai/src/backend/tools/executor.ts imports automations' routes.js directly. It is declared as a hard dependencies entry so the loader starts automations first and marks ai blocked if automations is disabled. In B18 ai calls automations through an automations service as an optionalDependency and the direct import goes away.
  • TODO(B18): plugins/host-metrics/src/backend/ reaches automations twice, for a MetricsSnapshot type and a fire-and-forget import("...headless-viewer.js") that registers a viewer bridge. Both degrade silently when automations is absent, so host-metrics declares it as an optionalDependency (added in A2; it previously declared nothing, and the loader had no reason to order the two).
  • Core feature servers that are not plugins yet still own ports: tunnel 30003, file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns a port any more (A4).
  • The terminal component and TerminalTabContent stay in core until the terminal's Phase B step (session manager, split view and file-manager callbacks). ssh-terminal registers them through the legacy alias.
  • Host data columns (enableDocker, enableRdp, guacamoleConfig and so on) stay in core types until Phase B moves them. Only UI branches on them left the shell.