# Termix plugin architecture This is the contract between Termix core and a plugin. It is the reference for anyone writing a plugin and the specification the runtime in `src/backend/plugins/` implements. Status: the redesign lands across several steps. Everything marked **A1** is built today. Later markers name the step that delivers the rest. --- ## The shape of it ### 1. One tier Every plugin, official or community, runs in-process in the Termix server and in the browser. There is no first-party allowlist, no worker tier, and no special case keyed on a plugin id anywhere in core. This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-does-not). ### 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/frontend` | The `app` object types (stubs until A7) | | `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema | | `@termix/plugin-sdk/capabilities` | The capability catalog | | `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` | Plugins never import from `src/` or `@/`, and core never imports from `plugins/`. Anything a plugin needs from core becomes a typed, documented SDK API. Enforced by lint, with the exceptions recorded under [legacy core imports](#legacy-core-imports-the-debt-d1-removes). 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 `.`. 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//NNNN_name.sql`. Table names are prefixed `p__`, 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 (`fleets` -> `p_fleets_fleets`) 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 the CLI at build time and by the runner at activation, because a plugin installed from a tarball never ran the CLI. **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. ### 6. HTTP and WebSockets Plugin routes live under `/plugin-api//` and sockets under `/plugin-ws//`, 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//`, 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 (with an optional custom component), stores values in `plugin_settings`, and encrypts secret fields. One Settings screen has a Plugins section with a page per plugin; host settings render in the host editor. **A6.** ### 8. UI A plugin frontend is a built ESM bundle loaded at runtime by the core loader. It registers everything through the `app` object: rail items, panels, tabs, host editor sections, host actions and badges, context menu items, palette entries, dashboard cards, homepage widgets, settings components, action slots, login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.** ### 9. Cross-plugin use Through `ctx.services` (typed, versioned, RBAC-checked per call), `ctx.events`, and frontend actions and slots. Dependencies are declared in the manifest: `dependencies` are hard, `optionalDependencies` are soft. The loader activates in dependency order. A plugin must keep working when an optional dependency is missing. ### 10. Lifecycle `activate(ctx)` creates all state. Everything registered through `ctx` or `app` is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules). ### 11. Tests Each plugin has `plugins//tests/backend` (node) and `plugins//tests/frontend` (jsdom), run by its own vitest config: ```ts // plugins//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, and `renderWithApp()` for frontend tests (**A7** finishes 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`. **A8.** ### 13. Where plugins live Official plugins live in `plugins//` 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// manifest.json id, capabilities, contributions package.json @termix-plugin/, 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() (A7) locales/en.json English strings; Crowdin translates the rest 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. --- ## Manifest v2 reference `plugins//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. ```jsonc { "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 }, ], "hostCapability": { "key": "enableExample", "labelKey": "k", "editorTab": "general", }, }, "backend": "dist/backend.js", // default "frontend": "dist/frontend.js", // default "locales": "locales", // default "platforms": ["linux", "win32", "darwin"], // optional } ``` `category` is one of: Terminal, Files & Transfer, Infrastructure, Monitoring, Networking, Access & Security, Productivity. `openFrom` is one of: rail, host-context-menu, palette. Cross-field rules the JSON schema cannot express, checked by `parseManifest`: - `engine.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 `.` 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`. `contributes.hostCapability`, `provides`, `requires`, `providesSecret` and `requiresSecret` are carried forward from v1 unchanged because the service and secret registries read them at activation. A6 and A7 reshape 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), `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..title` and `.consequence` in `src/ui/locales/en.json`, and the copy describes the consequence to the person deciding, not the mechanism. | Capability | Risk | Meaning | | -------------------- | -------- | -------------------------------------------------------------- | | `credentials:read` | critical | See plaintext secrets for any reachable host | | `ssh:connect` | high | Run commands on hosts it is connected to | | `process:spawn` | high | Run programs on the Termix server | | `users:write` | high | Create and change user accounts | | `auth:provide` | high | Add a login method or second factor | | `system:tls` | high | Request and replace the server certificate | | `hosts:write` | medium | Create and change hosts | | `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | | `network:outbound` | medium | Make outbound requests | | `network:serve` | medium | Open a listening port | | `users:read` | medium | See usernames and roles, never hashes or 2FA state | | `events:core` | medium | Subscribe to and emit core events carrying other users' data | | `notify:send` | medium | Send notifications through configured channels | | `audit:read` | medium | Read the audit log | | `desktop:window` | medium | Open desktop windows (Electron only) | | `hosts:read` | low | See the host list it can already see | | `db:own` | low | Own tables | | `kv:own` | low | Small key/value state | | `secrets:own` | low | Its own encrypted secrets | | `settings:read-core` | low | Read core server settings | | `ui:surface` | low | Contribute tabs, panels and settings | ### Rules - One catalog, in `packages/plugin-sdk/src/capabilities.ts`. Nothing else defines a capability. - Adding one is an entry here plus the SDK method that checks it. A capability nothing checks is decoration, and v1 shipped nineteen of those. - A capability is checked where the privileged thing happens, not at activation. Activation-time checks say what a plugin might do; call-time checks say what it did. - `events:core` is the exception that proves the shape: without it a plugin may only emit under `plugin..`, 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.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` | B | | `ctx.settings.*` | `settings:read-core` | A6 | | `ctx.notify.*` | `notify:send` | A6 | | `ctx.auth.*` | `auth:provide` | A8 | | `ctx.fetch` | `network:outbound` | B | ### 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__` 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//`, 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/jsx-runtime`, `@termix/plugin-sdk`, `i18next`, `react-i18next`, and the UI libraries core provides today (`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`, `guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these through the SDK `ui` entry. The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`. --- ## Legacy core imports: the debt D1 removes The twelve bundled plugins predate the SDK. 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. 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 components | Warning | 16 files | A7 | | A plugin importing core by relative path | Warning | 70 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). - `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in `BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.