diff --git a/docker/Dockerfile b/docker/Dockerfile index ec940647b..e9243ba2b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -103,6 +103,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend +# Bundled first-party plugins (ssh-terminal). Resolved as dist/plugins by +# getBundledPluginsDir(); without this the terminal never starts. +COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled COPY --chown=node:node package.json ./ # Schema for Postgres and MySQL. Unused by the default SQLite deployment, which diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index cf3a8957a..81f77ff68 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -381,6 +381,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/plugins(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/plugin-api(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 1efb0b637..57d8a598a 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -361,6 +361,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/plugins(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/plugin-api(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/electron-builder.json b/electron-builder.json index 1bd39332e..27ff1d47a 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -8,6 +8,7 @@ "asar": true, "asarUnpack": [ "dist/backend/**/*", + "dist/plugins/**/*", "node_modules/**/*", "public/icons/**/*", "public/icon.*" diff --git a/package.json b/package.json index 831758500..415634b35 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "dev": "vite", - "build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", - "build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", - "dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/starter.js", + "build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", + "build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", + "dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js", diff --git a/plugins/ssh-terminal/README.md b/plugins/ssh-terminal/README.md new file mode 100644 index 000000000..35bff76d9 --- /dev/null +++ b/plugins/ssh-terminal/README.md @@ -0,0 +1,70 @@ +# SSH Terminal + +The interactive SSH terminal, packaged as a first-party plugin. + +## Why this plugin runs in-process + +Every other plugin runs in a `worker_threads` worker and reaches the server +through a structured-clone `postMessage` boundary. That boundary is what makes +the capability gate and the audit trail mean anything: a worker plugin cannot +hold a db handle, an ssh2 `Client` or a live socket, so every privileged action +has to go through the broker. + +This plugin cannot work that way. It _is_ the SSH transport rather than a +consumer of one: + +- it owns a `WebSocketServer` on port 30002, +- it holds long-lived `ssh2.Client` objects and PTY streams, +- it needs plaintext host credentials to connect at all. + +None of those can cross a structured-clone boundary, and `ctx.hosts` returns a +13-field allowlist that deliberately excludes `password`, `key` and +`keyPassword`. So `ssh-terminal` runs on the main thread instead, under the +`process:transport-owner` tier. + +Membership in that tier is a hardcoded allowlist in +`src/backend/plugins/first-party.ts`. A manifest cannot ask for it: declaring +`process:transport-owner` without being on the list is a validation error. The +tier has no capability gate and no audit interception, which is only defensible +because nothing installable can enter it. + +## What lives where + +The terminal's implementation stays in `src/backend/hosts/terminal/`. Only the +lifecycle lives here. Two reasons it was not copied in: + +- `session-manager.ts` has six consumers outside the terminal (terminal routes, + open-tabs, user-image-storage, collab, and session-sharing twice). It is + shared infrastructure, not plugin-private code. +- host key verification (`host-key-verifier.ts`), jump host chaining + (`jump-host-chain.ts`) and host resolution (`host-resolver.ts`) each have + around eight consumers already. They keep working by _not_ moving. + +The SSH connection pool also stays in core. The terminal never used it: it +calls `new Client()` directly. The pool's consumers are metrics, tmux, tunnel, +fleet routes, AI tools, automations and `ctx.ssh` itself, so moving it here +would have made all of them depend on the terminal being enabled. + +Only `SSHAuthManager` is terminal-exclusive. + +## WebSocket: still port 30002, not /plugin-api/ + +The terminal WS was deliberately left on its own port and path. + +`/plugin-api` has no auth middleware, so routing terminal auth through it would +be a regression. Beyond that, `/ssh/websocket/` is mapped in both nginx configs, +three frontend call sites build that URL (`Terminal.tsx`, `SharedSessionView`, +`CollabRoomTab`), Electron's `buildOriginWsUrl` pins `localPort: 30002`, and the +8-attempt reconnect plus detach/reattach buffering all depend on the current +path. + +The plugin owns the port's lifecycle instead: enabling it calls +`startTerminalServer()`, disabling it calls `stopTerminalServer()`, which closes +live sessions and frees the port. + +## Disabling + +Disabling behaves like any other plugin. No confirmation dialog, no special +casing. The terminal tab stops being offered from the host list, command +palette and quick connect, the port stops listening, and every other feature +(metrics, docker, tunnels, file manager) keeps working off its own connections. diff --git a/plugins/ssh-terminal/backend/index.mjs b/plugins/ssh-terminal/backend/index.mjs new file mode 100644 index 000000000..0d98156bd --- /dev/null +++ b/plugins/ssh-terminal/backend/index.mjs @@ -0,0 +1,83 @@ +/** + * SSH Terminal - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker, because it IS + * the SSH transport rather than a consumer of one: it owns a WebSocket server + * and long-lived ssh2 clients, and neither a live socket nor an ssh2 Client can + * cross the structured-clone postMessage boundary a worker plugin talks over. + * See src/backend/plugins/first-party.ts for why that tier is a hardcoded + * allowlist and not something a manifest can ask for. + * + * The terminal's implementation stays in core (src/backend/hosts/terminal/) + * rather than being copied in here. Two reasons: + * - session-manager has six consumers outside the terminal (terminal routes, + * open-tabs, user-image-storage, collab, and session-sharing twice), so it + * is shared infrastructure, not plugin-private code. + * - the terminal needs plaintext host credentials to connect, which ctx.hosts + * deliberately never returns (PluginHostView is a 13-field allowlist that + * excludes password, key and passphrase). + * + * What this plugin owns is the lifecycle: enabling it starts the WS server, + * disabling it closes live sessions and frees the port. + */ + +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Resolves the compiled core terminal module. + * + * The plugin ships beside dist/backend, so in a built server the module is at + * dist/backend/backend/hosts/terminal/index.js. Under vitest and dev the + * plugin is loaded from the repo instead, where the source .ts is the thing + * that exists and tsx is already handling it. + */ +async function loadTerminalModule() { + const here = path.dirname(fileURLToPath(import.meta.url)); + const require = createRequire(import.meta.url); + + const candidates = [ + // Built: plugins/ssh-terminal/backend -> dist/backend/backend/... + path.resolve(here, "../../../backend/backend/hosts/terminal/index.js"), + // Repo layout, compiled output present. + path.resolve(here, "../../../dist/backend/backend/hosts/terminal/index.js"), + // Repo layout, running from source under tsx. + path.resolve(here, "../../../src/backend/hosts/terminal/index.ts"), + ]; + + for (const candidate of candidates) { + try { + require.resolve(candidate); + } catch { + continue; + } + return import(new URL(`file://${candidate.replace(/\\/g, "/")}`).href); + } + + throw new Error( + `ssh-terminal could not locate the terminal module. Looked in:\n ${candidates.join("\n ")}`, + ); +} + +let terminal = null; + +export async function activate(ctx) { + terminal = await loadTerminalModule(); + + await terminal.startTerminalServer(); + + // Published so collab and session-sharing can reach live sessions without + // importing a plugin. Revoked automatically when this plugin is disabled. + ctx.registry.provide("terminal.sessions", terminal.sessionManager ?? null); + + ctx.log.info( + `SSH terminal listening on ${terminal.TERMINAL_WS_PORT ?? 30002}`, + ); +} + +export async function deactivate() { + if (!terminal) return; + await terminal.stopTerminalServer(); + terminal = null; +} diff --git a/plugins/ssh-terminal/frontend/index.mjs b/plugins/ssh-terminal/frontend/index.mjs new file mode 100644 index 000000000..faac4d77d --- /dev/null +++ b/plugins/ssh-terminal/frontend/index.mjs @@ -0,0 +1,41 @@ +/** + * Frontend half of the ssh-terminal plugin. + * + * Registers the existing Terminal component through the extension seams that + * already exist in the shell (registerRailItem / registerTabComponent). The + * component itself is unchanged and still lives in src/ui/features/terminal - + * this only controls whether the shell knows about it. + * + * register() is called when the plugin is enabled, unregister() when it is + * disabled, so the tab and rail entry appear and disappear with the plugin. + */ + +export const id = "ssh-terminal"; +export const tabId = "terminal"; + +export async function register({ + registerRailItem, + registerTabComponent, + icons, +}) { + registerTabComponent(tabId, () => + import("@/features/terminal/Terminal").then((m) => ({ + default: m.Terminal, + })), + ); + + registerRailItem({ + id: tabId, + icon: icons.SquareTerminal, + labelKey: "nav.terminal", + kind: "tab", + }); +} + +export async function unregister({ + unregisterRailItem, + unregisterTabComponent, +}) { + unregisterRailItem(tabId); + unregisterTabComponent(tabId); +} diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json new file mode 100644 index 000000000..3a41ec934 --- /dev/null +++ b/plugins/ssh-terminal/manifest.json @@ -0,0 +1,30 @@ +{ + "id": "ssh-terminal", + "name": "SSH Terminal", + "version": "1.0.0", + "description": "Interactive SSH terminal sessions, with jump hosts, host key verification and session sharing.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Terminal", + "icon": "SquareTerminal", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "contributes": { + "tabs": [ + { + "id": "terminal", + "titleKey": "nav.terminal", + "icon": "SquareTerminal", + "openFrom": ["rail", "host-context-menu", "palette"] + } + ] + }, + "sidecars": [] +} diff --git a/scripts/copy-bundled-plugins.cjs b/scripts/copy-bundled-plugins.cjs new file mode 100644 index 000000000..da271b33f --- /dev/null +++ b/scripts/copy-bundled-plugins.cjs @@ -0,0 +1,33 @@ +/** + * Copies plugins/ into dist/plugins so bundled first-party plugins ship with a + * built server. + * + * tsc only emits .ts, and a plugin's backend entry is a hand-written .mjs plus + * a manifest.json, so without this step the plugins directory simply would not + * exist in dist and the loader would find nothing. + * + * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves + * dist/backend/backend/plugins -> dist/plugins, which is where this writes. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const source = path.join(root, "plugins"); +const destination = path.join(root, "dist", "plugins"); + +if (!fs.existsSync(source)) { + console.log("No plugins/ directory, nothing to bundle."); + process.exit(0); +} + +fs.rmSync(destination, { recursive: true, force: true }); +fs.cpSync(source, destination, { recursive: true }); + +const bundled = fs + .readdirSync(destination, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + +console.log(`Bundled ${bundled.length} plugin(s): ${bundled.join(", ")}`); diff --git a/scripts/plugin-manifest.schema.json b/scripts/plugin-manifest.schema.json index b34c8c652..645509867 100644 --- a/scripts/plugin-manifest.schema.json +++ b/scripts/plugin-manifest.schema.json @@ -119,6 +119,7 @@ "notify.send", "users.read", "process.sidecar", + "process:transport-owner", "ui.tab", "ui.rail", "ui.card", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index cc6275c1a..4b85fe68a 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -36,6 +36,7 @@ import aiRoutes from "../ai/index.js"; import automationsRoutes from "./routes/automations.js"; import syncRoutes from "./routes/sync.js"; import pluginApiRoutes from "./routes/plugin-api-routes.js"; +import pluginRoutes from "./routes/plugins.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js"; import fs from "fs"; @@ -1779,6 +1780,7 @@ app.use("/automations", automationsRoutes); app.use("/ai", aiRoutes); app.use("/", alertRulesRoutes); app.use("/sync", syncRoutes); +app.use("/plugins", pluginRoutes); app.use("/plugin-api", pluginApiRoutes); const frontendDistPaths = [ diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts new file mode 100644 index 000000000..17a8def9f --- /dev/null +++ b/src/backend/database/routes/plugins.ts @@ -0,0 +1,151 @@ +// Plugin management: what is installed, and enabling/disabling it. +// +// Distinct from plugin-api-routes.ts, which dispatches traffic INTO a running +// plugin. This is the control plane for the plugins themselves. + +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express, { type Request, type Response } from "express"; +import { databaseLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentPluginRepository } from "../repositories/factory.js"; +import { getPluginRuntime } from "../../plugins/index.js"; + +const router = express.Router(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +/** + * @openapi + * /plugins: + * get: + * summary: List installed plugins and their runtime state + * description: Returns every plugin known to the database, merged with the loader's live state so the UI can tell "enabled but crashed" from "disabled". + * tags: + * - Plugins + * responses: + * 200: + * description: List of plugins. + */ +router.get("/", authenticateJWT, async (_req: Request, res: Response) => { + try { + const records = await createCurrentPluginRepository().listAll(); + const { loader } = getPluginRuntime(); + const live = new Map(loader.list().map((p) => [p.id, p])); + + const plugins = records.map((record) => { + const loaded = live.get(record.id); + let contributes: unknown = null; + try { + contributes = JSON.parse(record.manifestJson)?.contributes ?? null; + } catch { + contributes = null; + } + + return { + id: record.id, + name: record.name, + version: record.version, + tier: record.tier, + source: record.source, + enabled: record.state === "enabled", + runtimeState: loaded?.state ?? "stopped", + lastError: loaded?.lastError ?? null, + contributes, + }; + }); + + res.json(plugins); + } catch (error) { + databaseLogger.error( + "Failed to list plugins", + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_list" }, + ); + res.status(500).json({ error: "Failed to list plugins" }); + } +}); + +/** + * @openapi + * /plugins/{id}/state: + * patch: + * summary: Enable or disable a plugin + * description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: The plugin's new state. + * 400: + * description: Invalid request body. + * 404: + * description: No such plugin. + */ +router.patch( + "/:id/state", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const pluginId = String(req.params.id); + const { enabled } = req.body ?? {}; + + if (typeof enabled !== "boolean") { + res.status(400).json({ error: "enabled must be a boolean" }); + return; + } + + try { + const repository = createCurrentPluginRepository(); + const record = await repository.findById(pluginId); + if (!record) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Persist first: if the start or stop below throws, the recorded + // intent still matches what the user asked for, and the next boot + // acts on it rather than silently reverting. + await repository.update(pluginId, { + state: enabled ? "enabled" : "disabled", + }); + + const { activatePlugin, deactivatePlugin } = + await import("../../plugins/index.js"); + + if (enabled) await activatePlugin(pluginId, userId); + else await deactivatePlugin(pluginId); + + databaseLogger.info( + `Plugin ${pluginId} ${enabled ? "enabled" : "disabled"}`, + { operation: "plugin_state_change", pluginId }, + ); + + res.json({ id: pluginId, enabled }); + } catch (error) { + databaseLogger.error( + `Failed to change state for plugin ${pluginId}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_state_change" }, + ); + res.status(500).json({ error: "Failed to change plugin state" }); + } + }, +); + +export default router; diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index f2106b95c..6818346fb 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -139,10 +139,25 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; const userConnections = new Map>(); -const wss = new WebSocketServer({ - host: "127.0.0.1", - port: 30002, -}); +export const TERMINAL_WS_PORT = 30002; + +// Re-exported so the ssh-terminal plugin can publish it on the service +// registry. session-manager itself stays in core: six modules outside the +// terminal import it directly. +export { sessionManager }; + +/** + * The server is created but not listening until startTerminalServer() runs. + * The ssh-terminal plugin owns that lifecycle, so disabling the plugin has to + * actually free the port rather than just stop routing to it. + * + * `noServer` avoids binding at import time; listen() below is what opens the + * port. Handlers are attached at module scope as before, so none of the + * connection logic below had to change. + */ +const wss = new WebSocketServer({ noServer: true }); + +let httpServer: import("http").Server | null = null; wss.on("error", (error) => { sshLogger.error("WebSocket server error", error, { @@ -150,6 +165,56 @@ wss.on("error", (error) => { }); }); +export async function startTerminalServer( + port: number = TERMINAL_WS_PORT, +): Promise { + if (httpServer) return; + + const { createServer } = await import("http"); + const server = createServer(); + + server.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + httpServer = server; + sshLogger.info(`Terminal WebSocket server listening on ${port}`, { + operation: "terminal_ws_start", + }); +} + +export async function stopTerminalServer(): Promise { + if (!httpServer) return; + + const server = httpServer; + httpServer = null; + + // Close live sockets first: server.close() waits for connections to drain, + // and a terminal session would otherwise hold the port open indefinitely. + for (const ws of wss.clients) { + try { + ws.close(1001, "Terminal plugin disabled"); + } catch { + // Already gone. + } + } + + await new Promise((resolve) => server.close(() => resolve())); + sshLogger.info("Terminal WebSocket server stopped", { + operation: "terminal_ws_stop", + }); +} + /** * Auth path for anonymous share-link guests (?shareToken=). * Never touches DataCrypto/user credentials - guests join an already-live diff --git a/src/backend/plugins/broker.ts b/src/backend/plugins/broker.ts index f826e33ff..a5adc402f 100644 --- a/src/backend/plugins/broker.ts +++ b/src/backend/plugins/broker.ts @@ -79,6 +79,8 @@ const MAX_STORAGE_KEY_LENGTH = 128; const MAX_STORAGE_VALUE_BYTES = 256 * 1024; const MIN_SCHEDULE_INTERVAL_MS = 1000; const HTTP_HANDLER_TIMEOUT_MS = 30_000; +/** Short: a disable should feel immediate even when a plugin is unresponsive. */ +const DEACTIVATE_TIMEOUT_MS = 5_000; const MAX_SSH_HANDLES = 8; const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000; const MAX_SSH_EXEC_TIMEOUT_MS = 120_000; @@ -605,6 +607,40 @@ export class PluginBroker { }); } + /** + * Gives a worker plugin a chance to close things down before it is killed. + * Resolves either way: a plugin that does not answer must not block the + * disable, it just loses its cleanup. + */ + requestDeactivate( + runtime: PluginRuntime, + timeoutMs = DEACTIVATE_TIMEOUT_MS, + ): Promise { + if (runtime.disposed) return Promise.resolve(); + + const replyTo = this.nextPushId++; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.httpWaiters.delete(replyTo); + resolve(); + }, timeoutMs); + + const settle = () => { + clearTimeout(timer); + resolve(); + }; + + this.httpWaiters.set(replyTo, { resolve: settle, reject: settle }); + this.push(runtime, { + kind: "deactivate", + key: "deactivate", + payload: null, + replyTo, + }); + }); + } + /** Main thread -> worker, for events, timers and route invocations. */ push(runtime: PluginRuntime, message: PluginPush): void { if (runtime.disposed) return; diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts new file mode 100644 index 000000000..0445a4246 --- /dev/null +++ b/src/backend/plugins/first-party.ts @@ -0,0 +1,58 @@ +/** + * The plugins that run in-process on the main thread instead of in a worker. + * + * ## Why this list is hardcoded + * + * Every other plugin is handed a `ctx` object and nothing else, across a + * structured-clone postMessage boundary. That boundary is what makes the + * capability gate and the audit trail meaningful: a worker plugin physically + * cannot hold a db handle, an ssh2 Client or a live socket, so every privileged + * action has to pass through the broker, which checks grants first and writes + * an audit line after. + * + * A plugin in this tier opts out of all of that. It is imported directly into + * the server process, so it has the same reach as any other backend module: no + * ctx boundary, no capability gate, no audit interception, no containment. It + * is Termix's own code that happens to be packaged and lifecycled as a plugin. + * + * That is only defensible because membership cannot be claimed. This set is + * compiled into the server. A manifest cannot opt in -- declaring the + * capability without being listed here is a validation error, not a silent + * downgrade (see parseManifest). Nothing read from the database, a registry or + * a plugin directory can add to it. + * + * DO NOT add an installed or community plugin here. If a plugin needs more + * reach than ctx allows, the answer is to widen ctx -- deliberately, with a + * gate and an audit line -- not to move it onto the main thread. + */ + +/** + * The only plugin ids that may run in-process. + * + * ssh-terminal is here because it IS the SSH transport rather than a consumer + * of it: it owns a WebSocket server and long-lived ssh2 clients, neither of + * which can cross a postMessage boundary. + */ +export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ + "ssh-terminal", +]); + +export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; + +export function isFirstParty(pluginId: string): boolean { + return FIRST_PARTY_PLUGIN_IDS.has(pluginId); +} + +/** + * Both conditions are required. The allowlist alone is not enough -- a + * first-party plugin that does not declare the capability still runs in a + * worker, so the manifest stays an honest description of what the plugin does. + */ +export function runsInProcess( + pluginId: string, + permissions: readonly string[], +): boolean { + return ( + isFirstParty(pluginId) && permissions.includes(TRANSPORT_OWNER_CAPABILITY) + ); +} diff --git a/src/backend/plugins/host-ctx.ts b/src/backend/plugins/host-ctx.ts new file mode 100644 index 000000000..665fb6697 --- /dev/null +++ b/src/backend/plugins/host-ctx.ts @@ -0,0 +1,173 @@ +/** + * The ctx handed to an in-process first-party plugin. + * + * Unlike the worker ctx built in worker-bootstrap.ts, nothing here is a + * postMessage round trip and nothing is capability-gated. There would be no + * point: the plugin is running on the main thread and could import any module + * in the server directly. A gate it can trivially bypass is a false claim, not + * a control, so this surface does not pretend to be one. + * + * What this ctx is actually for is lifecycle and identity: the plugin gets a + * logger tagged with its id, the shared event bus, its own storage namespace, + * and the service registry. It uses ordinary imports for everything else. + * + * See first-party.ts for why membership in this tier cannot be self-declared. + */ + +import { pluginLogger } from "../utils/logger.js"; +import { pluginEvents } from "./events.js"; +import * as registry from "./registry.js"; +import type { PluginManifest } from "./manifest.js"; + +export interface InProcessPluginContext { + pluginId: string; + manifest: PluginManifest; + log: { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string, error?: Error) => void; + }; + events: { + emit: (topic: string, payload: unknown) => void; + on: (topic: string, listener: (payload: unknown) => void) => () => void; + }; + storage: { + get: (key: string) => Promise; + set: (key: string, value: unknown) => Promise; + delete: (key: string) => Promise; + list: () => Promise; + }; + registry: { + provide: (key: string, value: T) => void; + consume: (key: string) => T | undefined; + revoke: (key: string, value?: unknown) => boolean; + }; +} + +/** + * Tracks what a plugin registered so deactivate can undo it. A first-party + * plugin owns real resources -- a listening port, live SSH sessions -- so + * "disabled" has to actually release them, not just stop routing to them. + */ +export interface InProcessHandle { + module: PluginModule; + /** Event unsubscribers, run on deactivate so no listener outlives the plugin. */ + unsubscribers: Array<() => void>; + /** Registry keys this plugin provided, revoked on deactivate. */ + providedKeys: Array<{ key: string; value: unknown }>; +} + +export interface PluginModule { + activate: (ctx: InProcessPluginContext) => void | Promise; + deactivate?: () => void | Promise; +} + +export function createInProcessContext( + manifest: PluginManifest, + handle: InProcessHandle, +): InProcessPluginContext { + const pluginId = manifest.id; + // Forced, like the worker path: a plugin cannot log as another plugin. + const context = { operation: `plugin:${pluginId}` }; + + return { + pluginId, + manifest, + + log: { + debug: (message) => pluginLogger.debug(message, context), + info: (message) => pluginLogger.info(message, context), + warn: (message) => pluginLogger.warn(message, context), + error: (message, error) => pluginLogger.error(message, error, context), + }, + + events: { + emit: (topic, payload) => pluginEvents.emit(topic, payload), + on: (topic, listener) => { + const unsubscribe = pluginEvents.on(topic, listener); + handle.unsubscribers.push(unsubscribe); + return unsubscribe; + }, + }, + + storage: { + get: async (key) => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + const raw = await createCurrentPluginStorageRepository().get( + pluginId, + key, + ); + if (raw === null) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + set: async (key, value) => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + await createCurrentPluginStorageRepository().set( + pluginId, + key, + JSON.stringify(value ?? null), + ); + }, + delete: async (key) => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + return createCurrentPluginStorageRepository().delete(pluginId, key); + }, + list: async () => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + return createCurrentPluginStorageRepository().listKeys(pluginId); + }, + }, + + registry: { + provide: (key, value) => { + registry.provide(key, value); + handle.providedKeys.push({ key, value }); + }, + consume: (key) => registry.consume(key), + revoke: (key, value) => registry.revoke(key, value), + }, + }; +} + +/** Undoes everything the ctx handed out. Safe to call more than once. */ +export async function disposeInProcessHandle( + handle: InProcessHandle, + pluginId: string, +): Promise { + for (const { key, value } of handle.providedKeys) { + registry.revoke(key, value); + } + handle.providedKeys = []; + + for (const unsubscribe of handle.unsubscribers) { + try { + unsubscribe(); + } catch { + // Already detached. + } + } + handle.unsubscribers = []; + + // The plugin's own cleanup runs last: it may still want to emit or read + // while shutting down. + if (typeof handle.module.deactivate === "function") { + try { + await handle.module.deactivate(); + } catch (error) { + pluginLogger.error( + `Plugin ${pluginId} deactivate() failed`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_deactivate" }, + ); + } + } +} diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index c943fc440..0f42ffbae 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -17,6 +17,7 @@ import { import { PluginBroker, type PluginRuntime } from "./broker.js"; import { buildPluginRouter } from "./http-bridge.js"; import { PluginLoader, type LoadedPlugin } from "./loader.js"; +import { isFirstParty, runsInProcess } from "./first-party.js"; let loader: PluginLoader | null = null; let broker: PluginBroker | null = null; @@ -129,11 +130,60 @@ export function getPluginRuntime(): { broker!.detach(plugin.id); unregisterPluginRouter(plugin.id); }, + onBeforeTerminate: async (plugin) => { + const runtime = broker!.get(plugin.id); + if (runtime) await broker!.requestDeactivate(runtime); + }, }); } return { loader, broker }; } +/** + * Makes sure a bundled first-party plugin has a row so it can be enabled and + * disabled like any other. + * + * Only the insert sets state: once the row exists, whatever the user chose + * wins. Re-enabling a plugin they disabled on every restart would be a bug, + * not a default. + */ +async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise { + const { createCurrentPluginRepository } = + await import("../database/repositories/factory.js"); + const repository = createCurrentPluginRepository(); + + for (const plugin of loaded) { + if (!isFirstParty(plugin.id)) continue; + + const existing = await repository.findById(plugin.id); + if (existing) { + // Keep metadata fresh across upgrades, but never touch `state`. + if (existing.version !== plugin.manifest.version) { + await repository.update(plugin.id, { + version: plugin.manifest.version, + name: plugin.manifest.name, + manifestJson: JSON.stringify(plugin.manifest), + }); + } + continue; + } + + await repository.create({ + id: plugin.id, + name: plugin.manifest.name, + version: plugin.manifest.version, + tier: "first-party", + source: "bundled", + state: "enabled", + manifestJson: JSON.stringify(plugin.manifest), + }); + + pluginLogger.info(`Registered bundled plugin ${plugin.id}`, { + operation: "plugin_seed", + }); + } +} + /** * Loads every plugin on disk and activates the ones marked enabled in the * database. Called once from the backend start-up sequence. @@ -144,6 +194,8 @@ export async function initializePlugins(): Promise { const loaded = await pluginLoader.loadAll(); if (loaded.length === 0) return []; + await seedBundledPlugins(loaded); + const { createCurrentPluginRepository, createCurrentPluginPermissionGrantRepository, @@ -156,6 +208,21 @@ export async function initializePlugins(): Promise { const record = byId.get(plugin.id); if (!record || record.state !== "enabled") continue; + // An in-process first-party plugin never uses the gated ctx, so it has no + // owner to act as and must not be held to the grant requirement below. + if (runsInProcess(plugin.id, plugin.manifest.permissions)) { + try { + await activatePlugin(plugin.id); + } catch (error) { + pluginLogger.error( + `Failed to activate plugin ${plugin.id}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_activate" }, + ); + } + continue; + } + // The plugins table records no installer, so the plugin acts under the // authority of whoever granted its capabilities. Without a grant there is // no owner and nothing the plugin could do with one, so it stays stopped. @@ -189,7 +256,7 @@ export async function initializePlugins(): Promise { export async function activatePlugin( pluginId: string, - ownerUserId: string, + ownerUserId?: string, ): Promise { const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime(); await pluginLoader.activate(pluginId, ownerUserId); diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index d253dfb2b..af374112f 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -41,11 +41,19 @@ import { Worker } from "node:worker_threads"; import { pluginLogger } from "../utils/logger.js"; import { parseManifest, type PluginManifest } from "./manifest.js"; import { + getBundledPluginsDir, getPluginBackendEntry, getPluginManifestPath, getPluginsDir, } from "./paths.js"; import type { PluginBootstrapData } from "./protocol.js"; +import { runsInProcess } from "./first-party.js"; +import { + createInProcessContext, + disposeInProcessHandle, + type InProcessHandle, + type PluginModule, +} from "./host-ctx.js"; export type PluginState = | "loaded" @@ -68,10 +76,19 @@ export interface LoadedPlugin { manifest: PluginManifest; state: PluginState; worker: Worker | null; + /** + * Set instead of `worker` for first-party plugins that run on the main + * thread. Exactly one of the two is ever non-null. + */ + inProcess: InProcessHandle | null; /** Consecutive crashes since the last successful activation. */ restartAttempts: number; lastError: string | null; - /** The user whose authority this plugin acts under. Set at activate time. */ + /** + * The user whose authority this plugin acts under. Set at activate time. + * Null for in-process plugins, which do not use the gated ctx and so have no + * owner to act as. + */ ownerUserId: string | null; } @@ -83,6 +100,11 @@ export interface PluginLoaderOptions { */ onWorkerReady?: (plugin: LoadedPlugin, worker: Worker) => void; onWorkerGone?: (plugin: LoadedPlugin) => void; + /** + * Gives a worker plugin a chance to run its deactivate() before it is + * terminated. A hook rather than a broker import, same as the two above. + */ + onBeforeTerminate?: (plugin: LoadedPlugin) => Promise; /** Overridable so tests do not have to wait out the real backoff. */ restartBackoffMs?: number[]; /** How long a plugin must stay up before its crash counter resets. */ @@ -172,6 +194,7 @@ export class PluginLoader { manifest, state: "loaded", worker: null, + inProcess: null, restartAttempts: 0, lastError: null, ownerUserId: null, @@ -184,31 +207,56 @@ export class PluginLoader { return plugin; } - /** Scans the plugins directory, loading every valid plugin it finds. */ + /** + * Scans for plugins, loading every valid one it finds. + * + * Bundled first-party plugins are scanned first and a user-installed + * directory can never shadow one: dropping an "ssh-terminal" folder into the + * data directory must not replace the real transport owner. + */ async loadAll(): Promise { - const root = getPluginsDir(); - if (!fs.existsSync(root)) return []; - - const entries = await fs.promises.readdir(root, { withFileTypes: true }); const loaded: LoadedPlugin[] = []; + const seen = new Set(); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - try { - loaded.push(await this.load(path.join(root, entry.name))); - } catch (error) { - pluginLogger.error( - `Skipping plugin directory ${entry.name}`, - error instanceof Error ? error : new Error(String(error)), - { operation: "plugin_load" }, - ); + for (const root of [getBundledPluginsDir(), getPluginsDir()]) { + if (!fs.existsSync(root)) continue; + + const entries = await fs.promises.readdir(root, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + if (seen.has(entry.name)) { + pluginLogger.warn( + `Ignoring plugin directory ${entry.name} in ${root}: a bundled plugin already uses that id`, + { operation: "plugin_load" }, + ); + continue; + } + + try { + const plugin = await this.load(path.join(root, entry.name)); + seen.add(plugin.id); + loaded.push(plugin); + } catch (error) { + pluginLogger.error( + `Skipping plugin directory ${entry.name}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_load" }, + ); + } } } return loaded; } - async activate(pluginId: string, ownerUserId: string): Promise { + /** + * `ownerUserId` is optional only for in-process first-party plugins, which + * do not use the gated ctx and so never act as a user. A worker plugin + * without one cannot reach any user data (see broker.requireOwner). + */ + async activate(pluginId: string, ownerUserId?: string): Promise { const plugin = this.requirePlugin(pluginId); if (plugin.state === "active" || plugin.state === "activating") return; @@ -216,7 +264,7 @@ export class PluginLoader { throw new Error(`Plugin ${pluginId} has no backend to activate`); } - plugin.ownerUserId = ownerUserId; + plugin.ownerUserId = ownerUserId ?? null; await this.spawn(plugin); } @@ -224,6 +272,11 @@ export class PluginLoader { plugin.state = "activating"; plugin.lastError = null; + if (runsInProcess(plugin.id, plugin.manifest.permissions)) { + await this.spawnInProcess(plugin); + return; + } + const bootstrap: PluginBootstrapData = { pluginId: plugin.id, pluginDir: plugin.dir, @@ -263,6 +316,61 @@ export class PluginLoader { await this.awaitActivation(plugin, worker); } + /** + * Starts a first-party plugin on the main thread. + * + * There is no worker, so there is no crash isolation: an exception thrown + * later by this plugin's own callbacks lands wherever it was thrown, exactly + * as it would from any other backend module. The restart accounting below + * therefore only covers activation failure, which is the one thing we can + * still observe from here. See first-party.ts. + */ + private async spawnInProcess(plugin: LoadedPlugin): Promise { + const entry = pathToFileUrlString(getPluginBackendEntry(plugin.dir)); + + try { + const imported = (await import(entry)) as { + activate?: PluginModule["activate"]; + deactivate?: PluginModule["deactivate"]; + default?: PluginModule; + }; + + const activate = imported.activate ?? imported.default?.activate; + const deactivate = imported.deactivate ?? imported.default?.deactivate; + + if (typeof activate !== "function") { + throw new Error( + `Plugin ${plugin.id} backend entry does not export an activate(ctx) function`, + ); + } + + const handle: InProcessHandle = { + module: { activate, deactivate }, + unsubscribers: [], + providedKeys: [], + }; + + const ctx = createInProcessContext(plugin.manifest, handle); + await withTimeout( + Promise.resolve(activate(ctx)), + ACTIVATION_TIMEOUT_MS, + `Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`, + ); + + plugin.inProcess = handle; + plugin.state = "active"; + this.scheduleStabilityReset(plugin); + pluginLogger.success(`Activated plugin ${plugin.id} (in-process)`, { + operation: "plugin_activate", + }); + } catch (error) { + plugin.lastError = error instanceof Error ? error.message : String(error); + plugin.inProcess = null; + plugin.state = "crashed"; + throw error instanceof Error ? error : new Error(plugin.lastError); + } + } + private awaitActivation(plugin: LoadedPlugin, worker: Worker): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -401,7 +509,12 @@ export class PluginLoader { plugin.state = "loaded"; plugin.lastError = null; - if (!plugin.ownerUserId) { + // In-process plugins legitimately have no owner, so only worker plugins + // are held to this. + if ( + !plugin.ownerUserId && + !runsInProcess(plugin.id, plugin.manifest.permissions) + ) { throw new Error( `Plugin ${pluginId} has never been activated, so there is nothing to retry`, ); @@ -423,7 +536,7 @@ export class PluginLoader { this.stabilityTimers.delete(pluginId); } - if (!plugin.worker) { + if (!plugin.worker && !plugin.inProcess) { plugin.state = "stopped"; return; } @@ -434,8 +547,29 @@ export class PluginLoader { } private async terminate(plugin: LoadedPlugin): Promise { + // An in-process plugin owns real resources -- a listening port, live SSH + // sessions -- and there is no thread to kill, so its own cleanup is the + // only thing that releases them. + if (plugin.inProcess) { + const handle = plugin.inProcess; + plugin.inProcess = null; + this.options.onWorkerGone?.(plugin); + await disposeInProcessHandle(handle, plugin.id); + return; + } + const worker = plugin.worker; if (!worker) return; + + // Only on a deliberate stop. A crashed worker has nothing left to ask. + if (plugin.state === "stopping" && this.options.onBeforeTerminate) { + try { + await this.options.onBeforeTerminate(plugin); + } catch { + // Cleanup is best-effort; the worker is going away regardless. + } + } + plugin.worker = null; try { await worker.terminate(); @@ -476,6 +610,26 @@ function pickTempEnv(): Record { return env; } +function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function pathToFileUrlString(filePath: string): string { const resolved = path.resolve(filePath).replace(/\\/g, "/"); return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`; diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index a8643ba61..c9b09ed59 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -11,6 +11,8 @@ * runtime, for the same reason. If you change the schema, change both. */ +import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js"; + export const PLUGIN_PERMISSIONS = [ "hosts.read", "hosts.write", @@ -24,6 +26,10 @@ export const PLUGIN_PERMISSIONS = [ "notify.send", "users.read", "process.sidecar", + // Reserved for first-party plugins. parseManifest refuses it for any id not + // on the hardcoded allowlist in first-party.ts, so declaring it does not + // grant it. + "process:transport-owner", "ui.tab", "ui.rail", "ui.card", @@ -325,5 +331,19 @@ export function parseManifest(raw: unknown): { }; } + // Refused outright rather than ignored: a plugin that asked to run in-process + // and was quietly downgraded to a worker would fail later in a confusing way, + // and the manifest would still claim reach it does not have. + if ( + manifest.permissions.includes(TRANSPORT_OWNER_CAPABILITY) && + !isFirstParty(manifest.id) + ) { + return { + errors: [ + `Permission "${TRANSPORT_OWNER_CAPABILITY}" is reserved for first-party plugins and cannot be declared by "${manifest.id}"`, + ], + }; + } + return { manifest, errors: [] }; } diff --git a/src/backend/plugins/paths.ts b/src/backend/plugins/paths.ts index fa9fe21cb..8788d9e74 100644 --- a/src/backend/plugins/paths.ts +++ b/src/backend/plugins/paths.ts @@ -1,4 +1,6 @@ +import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; /** * Where unpacked plugins live. Resolved per call rather than captured at module @@ -9,6 +11,37 @@ export function getPluginsDir(): string { return path.join(process.env.DATA_DIR || "./db/data", "plugins"); } +/** + * Where first-party plugins that ship with Termix live. + * + * Separate from getPluginsDir() because that one is user data: a bundled + * plugin is part of the install and must not be deletable or shadowable by + * whatever happens to be in the data directory. + * + * Resolved from this module's location rather than cwd, same reasoning as the + * worker bootstrap: the build emits to dist/backend/backend/plugins/, and the + * bundled plugins sit beside dist/backend. + */ +export function getBundledPluginsDir(): string { + const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR; + if (override) return override; + + const here = path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + // Built server: dist/backend/backend/plugins -> dist/plugins + path.resolve(here, "../../../plugins"), + // Running from source under vitest/tsx: src/backend/plugins -> plugins + path.resolve(here, "../../../plugins"), + path.resolve(here, "../../../../plugins"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return candidates[0]; +} + export function getPluginDir(pluginId: string): string { return path.join(getPluginsDir(), pluginId); } diff --git a/src/backend/plugins/protocol.ts b/src/backend/plugins/protocol.ts index 7ad07a32d..47685e0ab 100644 --- a/src/backend/plugins/protocol.ts +++ b/src/backend/plugins/protocol.ts @@ -31,7 +31,7 @@ export type PluginResponse = PluginResponseOk | PluginResponseErr; /** Main thread -> worker, pushed rather than requested. */ export interface PluginPush { - kind: "event" | "http" | "timer"; + kind: "event" | "http" | "timer" | "deactivate"; /** Event topic, route key, or timer id depending on kind. */ key: string; payload: unknown; diff --git a/src/backend/plugins/registry.ts b/src/backend/plugins/registry.ts new file mode 100644 index 000000000..703129e61 --- /dev/null +++ b/src/backend/plugins/registry.ts @@ -0,0 +1,59 @@ +/** + * A small main-thread service registry. + * + * This exists so a module can depend on a capability without depending on + * whichever module happens to provide it. Two uses today: + * + * - core registers the SSH connection pool as "ssh.transport" at boot, so + * everything that needs a pooled connection resolves a provider rather + * than importing the pool directly. + * - the ssh-terminal plugin publishes "terminal.sessions" so collab and + * session-sharing can reach live sessions without importing a plugin. + * + * Deliberately NOT reachable from a worker plugin's ctx. Values here are live + * objects -- pools, session managers, sockets -- and handing one across the + * postMessage boundary is exactly what that boundary exists to prevent. Only + * in-process first-party plugins get a reference to this. + * + * `consume` returns undefined rather than throwing when nothing is registered. + * A provider can disappear when its plugin is disabled, and callers are + * expected to handle that rather than assume it is always there. + */ + +import { pluginLogger } from "../utils/logger.js"; + +const providers = new Map(); + +export function provide(key: string, value: T): void { + if (providers.has(key)) { + pluginLogger.warn( + `Service "${key}" is already registered and is being replaced`, + { operation: "plugin_registry" }, + ); + } + providers.set(key, value); +} + +export function consume(key: string): T | undefined { + return providers.get(key) as T | undefined; +} + +/** + * Removes a provider, but only if `value` is the one currently registered. + * A plugin that crashed and restarted must not revoke the replacement its own + * restart installed, so revocation is identity-checked rather than by key. + */ +export function revoke(key: string, value?: unknown): boolean { + if (!providers.has(key)) return false; + if (value !== undefined && providers.get(key) !== value) return false; + return providers.delete(key); +} + +export function has(key: string): boolean { + return providers.has(key); +} + +/** Test seam. */ +export function clearRegistry(): void { + providers.clear(); +} diff --git a/src/backend/plugins/worker-bootstrap.ts b/src/backend/plugins/worker-bootstrap.ts index b7ce34c2c..11568103b 100644 --- a/src/backend/plugins/worker-bootstrap.ts +++ b/src/backend/plugins/worker-bootstrap.ts @@ -60,6 +60,21 @@ function handleResponse(message: PluginResponse): void { } async function handlePush(push: PluginPush): Promise { + if (push.kind === "deactivate") { + // Always acknowledge, even when the plugin has no hook or its hook threw: + // the main thread is waiting before it terminates us, and a missing reply + // would just stall that until its timeout. + try { + if (pluginDeactivate) await pluginDeactivate(); + } catch { + // Nothing useful to do here; the worker is about to be terminated. + } + if (push.replyTo !== undefined) { + port!.postMessage({ id: push.replyTo, ok: true, value: null }); + } + return; + } + if (push.kind === "event") { const listeners = eventListeners.get(push.key); if (!listeners) return; @@ -218,15 +233,24 @@ const ctx = { fetch: notImplemented("fetch"), }; +/** + * Held so a deactivate push can call it. A worker plugin is still terminated + * afterwards either way -- this only gives it a chance to close things + * cleanly first, which matters for anything holding a remote resource. + */ +let pluginDeactivate: (() => void | Promise) | null = null; + async function start(): Promise { const module = await import(boot.entryPath); const activate = module.activate ?? module.default?.activate; + const deactivate = module.deactivate ?? module.default?.deactivate; if (typeof activate !== "function") { throw new Error( `Plugin ${boot.pluginId} backend entry does not export an activate(ctx) function`, ); } + if (typeof deactivate === "function") pluginDeactivate = deactivate; await activate(ctx); port!.postMessage({ id: 0, ok: true, value: "activated" }); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index a18419223..c2389b196 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -295,7 +295,8 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { serverReady } = await import("./database/database.js"); await serverReady; - await import("./hosts/terminal/index.js"); + // Terminal is deliberately absent: the ssh-terminal plugin starts it, so + // disabling that plugin stops the WS server. See plugins/ssh-terminal. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/metrics/index.js"); diff --git a/src/backend/tests/hosts/terminal/server-lifecycle.test.ts b/src/backend/tests/hosts/terminal/server-lifecycle.test.ts new file mode 100644 index 000000000..c33ef88a1 --- /dev/null +++ b/src/backend/tests/hosts/terminal/server-lifecycle.test.ts @@ -0,0 +1,82 @@ +/** + * The terminal WS server's start/stop lifecycle, which the ssh-terminal plugin + * drives. The point of this test is the observable consequence of disabling + * the plugin: the port actually stops listening. + * + * The terminal module is heavy (it pulls in the whole SSH stack), so this + * exercises only the lifecycle functions, on a non-default port. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import net from "node:net"; + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({}), + createCurrentCollabRoomRepository: () => ({}), + createCurrentSessionShareRepository: () => ({}), + createCurrentSettingsRepository: () => ({}), + createCurrentSessionRecordingRepository: () => ({}), +})); + +const TEST_PORT = 39002; + +function isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} + +describe("terminal server lifecycle", () => { + afterEach(async () => { + const { stopTerminalServer } = + await import("../../../hosts/terminal/index.js"); + await stopTerminalServer(); + }); + + it("does not listen until it is started", async () => { + await import("../../../hosts/terminal/index.js"); + // Importing the module must not bind the port: the plugin decides that. + expect(await isListening(TEST_PORT)).toBe(false); + }); + + it("listens after start and frees the port after stop", async () => { + const { startTerminalServer, stopTerminalServer } = + await import("../../../hosts/terminal/index.js"); + + await startTerminalServer(TEST_PORT); + expect(await isListening(TEST_PORT)).toBe(true); + + await stopTerminalServer(); + // This is what "disabling the plugin" has to mean for a transport owner. + expect(await isListening(TEST_PORT)).toBe(false); + }); + + it("is idempotent on both ends", async () => { + const { startTerminalServer, stopTerminalServer } = + await import("../../../hosts/terminal/index.js"); + + await startTerminalServer(TEST_PORT); + await startTerminalServer(TEST_PORT); + expect(await isListening(TEST_PORT)).toBe(true); + + await stopTerminalServer(); + await stopTerminalServer(); + expect(await isListening(TEST_PORT)).toBe(false); + }); + + it("can be restarted, so re-enabling the plugin works", async () => { + const { startTerminalServer, stopTerminalServer } = + await import("../../../hosts/terminal/index.js"); + + await startTerminalServer(TEST_PORT); + await stopTerminalServer(); + await startTerminalServer(TEST_PORT); + + expect(await isListening(TEST_PORT)).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts new file mode 100644 index 000000000..167244755 --- /dev/null +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -0,0 +1,84 @@ +/** + * The real bundled ssh-terminal plugin, as it ships. + * + * This reads plugins/ in the repo rather than a fixture: the manifest is a + * checked-in artifact, and a typo in it would otherwise only show up at boot. + */ + +import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { getBundledPluginsDir } from "../../plugins/paths.js"; +import { parseManifest } from "../../plugins/manifest.js"; +import { + isFirstParty, + runsInProcess, + TRANSPORT_OWNER_CAPABILITY, +} from "../../plugins/first-party.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +function readBundledManifest(pluginId: string): Record { + const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json"); + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +describe("bundled ssh-terminal plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "ssh-terminal"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("ssh-terminal"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("ssh-terminal"); + expect(manifest?.category).toBe("Terminal"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("ssh-terminal")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("ssh-terminal")).toBe(true); + expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true); + }); + + it("contributes the terminal tab the shell registers", () => { + const { manifest } = parseManifest(readBundledManifest("ssh-terminal")); + const tab = manifest?.contributes?.tabs?.[0]; + + // The shell keys tab content off this id, so it has to stay "terminal". + expect(tab?.id).toBe("terminal"); + expect(tab?.openFrom).toContain("rail"); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "ssh-terminal", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + // A first-party plugin owns a listening port, so deactivate is not + // optional here the way it is for an ordinary plugin. + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/disable-isolation.test.ts b/src/backend/tests/plugins/disable-isolation.test.ts new file mode 100644 index 000000000..a682cdc7c --- /dev/null +++ b/src/backend/tests/plugins/disable-isolation.test.ts @@ -0,0 +1,180 @@ +/** + * The central promise of shipping the terminal as a plugin: disabling it + * releases what it owns, and nothing else notices. + * + * The SSH connection pool deliberately stayed in core, so a worker plugin's + * ctx.ssh keeps working with the terminal disabled. If the pool had moved into + * the terminal plugin, these tests would fail, which is the point of them. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import net from "node:net"; +import { PluginLoader } from "../../plugins/loader.js"; +import { PluginBroker } from "../../plugins/broker.js"; +import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js"; +import { clearRegistry } from "../../plugins/registry.js"; +import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../plugins/permissions.js", () => ({ + assertCapability: vi.fn().mockResolvedValue(undefined), + hasCapability: vi.fn().mockResolvedValue(true), + PluginPermissionError: class extends Error {}, + invalidatePluginPermissionCache: vi.fn(), +})); + +const TEST_PORT = 39102; + +function isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} + +/** Stands in for the terminal: a first-party plugin that owns a real port. */ +function terminalLikeSource(port: number): string { + return ` +import net from "node:net"; +let server = null; +export async function activate(ctx) { + server = net.createServer(); + await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve)); + ctx.registry.provide("terminal.sessions", { live: true }); +} +export async function deactivate() { + if (!server) return; + await new Promise((resolve) => server.close(resolve)); + server = null; +} +`; +} + +describe("disabling the terminal plugin", () => { + const fixtures: Fixture[] = []; + let loader: PluginLoader | null = null; + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + for (const fixture of fixtures.splice(0)) fixture.cleanup(); + clearRegistry(); + }); + + it("frees its port and leaves another plugin's ctx.ssh working", async () => { + const terminal = createFixturePlugin({ + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + manifestOverrides: { category: "Terminal" }, + backendSource: terminalLikeSource(TEST_PORT), + }); + fixtures.push(terminal); + + const other = createFixturePlugin({ + id: "other-plugin", + permissions: ["ssh.exec"], + // Exposes an HTTP route the test invokes after the terminal is + // disabled, so the ctx.ssh round trip genuinely happens afterwards and + // travels the real worker boundary. + backendSource: ` +export async function activate(ctx) { + await ctx.http.route("GET", "/run", async () => { + const handle = await ctx.ssh.connect(1); + const result = await ctx.ssh.exec(handle, "uptime"); + await ctx.ssh.close(handle); + return result; + }); +} +`, + }); + fixtures.push(other); + + // Stands in for the core pool. The terminal plugin does not provide it, + // so disabling the terminal must not affect it. + const open = vi.fn().mockResolvedValue({ + release: vi.fn(), + exec: vi + .fn() + .mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }), + }); + + const broker = new PluginBroker({ + listHosts: async () => [], + resolveHost: async () => null, + ssh: { open }, + }); + + loader = new PluginLoader({ + onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), + onWorkerGone: (plugin) => broker.detach(plugin.id), + }); + + await loader.load(terminal.dir); + await loader.load(other.dir); + await loader.activate("ssh-terminal"); + await loader.activate("other-plugin", "user-1"); + + expect(await isListening(TEST_PORT)).toBe(true); + + await loader.deactivate("ssh-terminal"); + + // What the user sees: the terminal's port is gone. + expect(await isListening(TEST_PORT)).toBe(false); + expect(loader.get("ssh-terminal")?.state).toBe("stopped"); + + // And the other plugin is untouched, still running off the core pool. + const otherPlugin = loader.get("other-plugin"); + expect(otherPlugin?.state).toBe("active"); + + // The real test: a ctx.ssh round trip completed AFTER the terminal was + // disabled, off the core pool, across the worker boundary. + const runtime = broker.get("other-plugin")!; + const result = (await broker.invokeRoute(runtime, "GET /run", { + method: "GET", + path: "/run", + params: {}, + query: {}, + body: null, + headers: {}, + userId: "user-1", + })) as { stdout: string }; + + expect(open).toHaveBeenCalled(); + expect(result.stdout).toBe("up 3 days"); + }); + + it("can be re-enabled after being disabled", async () => { + const terminal = createFixturePlugin({ + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + manifestOverrides: { category: "Terminal" }, + backendSource: terminalLikeSource(TEST_PORT + 1), + }); + fixtures.push(terminal); + + loader = new PluginLoader(); + await loader.load(terminal.dir); + + await loader.activate("ssh-terminal"); + expect(await isListening(TEST_PORT + 1)).toBe(true); + + await loader.deactivate("ssh-terminal"); + expect(await isListening(TEST_PORT + 1)).toBe(false); + + await loader.activate("ssh-terminal"); + expect(await isListening(TEST_PORT + 1)).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts new file mode 100644 index 000000000..1b7b4c541 --- /dev/null +++ b/src/backend/tests/plugins/first-party.test.ts @@ -0,0 +1,113 @@ +/** + * The first-party tier is the one place in the plugin system where code runs + * without the worker boundary, so the thing most worth testing is that it + * cannot be entered by a plugin that says it should be. + */ + +import { describe, expect, it } from "vitest"; +import { + FIRST_PARTY_PLUGIN_IDS, + isFirstParty, + runsInProcess, + TRANSPORT_OWNER_CAPABILITY, +} from "../../plugins/first-party.js"; +import { + parseManifest, + SUPPORTED_PLUGIN_API_VERSION, +} from "../../plugins/manifest.js"; + +function manifest(overrides: Record = {}) { + return { + id: "sample-plugin", + name: "Sample Plugin", + version: "1.0.0", + description: "A plugin.", + author: { name: "Someone" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, + capabilities: { + backend: true, + frontend: false, + electron: false, + platforms: ["linux"], + }, + permissions: [] as string[], + sidecars: [], + ...overrides, + }; +} + +describe("first-party allowlist", () => { + it("contains only ssh-terminal", () => { + expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]); + }); + + it("recognises ssh-terminal and nothing else", () => { + expect(isFirstParty("ssh-terminal")).toBe(true); + expect(isFirstParty("ssh-terminal-pro")).toBe(false); + expect(isFirstParty("community-plugin")).toBe(false); + expect(isFirstParty("")).toBe(false); + }); +}); + +describe("runsInProcess", () => { + it("requires both the allowlist and the declared capability", () => { + expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe( + true, + ); + + // On the list but not asking for it: stays in a worker, so the manifest + // remains an honest description of what the plugin does. + expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false); + + // Asking for it but not on the list. + expect( + runsInProcess("community-plugin", [TRANSPORT_OWNER_CAPABILITY]), + ).toBe(false); + + expect(runsInProcess("community-plugin", ["hosts.read"])).toBe(false); + }); +}); + +describe("manifest gate on the reserved capability", () => { + it("refuses the capability for a plugin that is not first-party", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ permissions: [TRANSPORT_OWNER_CAPABILITY] }), + ); + + expect(parsed).toBeUndefined(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(errors[0]).toContain("reserved for first-party plugins"); + }); + + it("refuses it even when mixed in with ordinary permissions", () => { + const { manifest: parsed } = parseManifest( + manifest({ + permissions: ["hosts.read", TRANSPORT_OWNER_CAPABILITY, "storage.own"], + }), + ); + expect(parsed).toBeUndefined(); + }); + + it("allows it for ssh-terminal", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("ssh-terminal"); + }); + + it("still accepts an ordinary manifest that never mentions it", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ permissions: ["hosts.read"] }), + ); + expect(errors).toEqual([]); + expect(parsed?.id).toBe("sample-plugin"); + }); +}); diff --git a/src/backend/tests/plugins/in-process-loader.test.ts b/src/backend/tests/plugins/in-process-loader.test.ts new file mode 100644 index 000000000..3b5d69ed0 --- /dev/null +++ b/src/backend/tests/plugins/in-process-loader.test.ts @@ -0,0 +1,200 @@ +/** + * The in-process branch of the loader: a first-party plugin runs on the main + * thread, with no worker, and its deactivate() actually gets awaited. + * + * These use the real ssh-terminal id because the allowlist is hardcoded and + * that is the point -- there is deliberately no way to fake membership. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { PluginLoader } from "../../plugins/loader.js"; +import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js"; +import { clearRegistry, consume } from "../../plugins/registry.js"; +import { pluginEvents } from "../../plugins/events.js"; +import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const FIRST_PARTY_OPTIONS = { + id: "ssh-terminal", + permissions: [TRANSPORT_OWNER_CAPABILITY], + manifestOverrides: { category: "Terminal" }, +}; + +/** + * Writes a marker file rather than using a module-level variable: the plugin + * is imported by URL into the same process, but ESM caching means a second + * fixture at a different path is a different module instance. + */ +function backendWritingMarker(markerPath: string, extra = ""): string { + const literal = JSON.stringify(markerPath); + return ` +import fs from "node:fs"; +export async function activate(ctx) { + fs.writeFileSync(${literal}, "activated"); + ${extra} +} +export async function deactivate() { + fs.appendFileSync(${literal}, ":deactivated"); +} +`; +} + +describe("in-process first-party plugins", () => { + let fixture: Fixture | null = null; + let loader: PluginLoader | null = null; + + beforeEach(() => { + clearRegistry(); + pluginEvents.clear(); + }); + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + fixture?.cleanup(); + fixture = null; + clearRegistry(); + pluginEvents.clear(); + }); + + it("activates on the main thread with no worker and no owner", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + const marker = path.join(fixture.root, "marker.txt"); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + backendWritingMarker(marker), + ); + + const onWorkerReady = vi.fn(); + loader = new PluginLoader({ onWorkerReady }); + + const plugin = await loader.load(fixture.dir); + // No ownerUserId: an in-process plugin never acts as a user. + await loader.activate(plugin.id); + + expect(plugin.state).toBe("active"); + expect(plugin.worker).toBeNull(); + expect(plugin.inProcess).not.toBeNull(); + expect(plugin.ownerUserId).toBeNull(); + // The broker attaches via this hook; an in-process plugin bypasses it. + expect(onWorkerReady).not.toHaveBeenCalled(); + expect(fs.readFileSync(marker, "utf8")).toBe("activated"); + }); + + it("awaits deactivate() on disable and clears the handle", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + const marker = path.join(fixture.root, "marker.txt"); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + backendWritingMarker(marker), + ); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id); + await loader.deactivate(plugin.id); + + expect(plugin.state).toBe("stopped"); + expect(plugin.inProcess).toBeNull(); + expect(fs.readFileSync(marker, "utf8")).toBe("activated:deactivated"); + }); + + it("revokes registry entries the plugin provided", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + const marker = path.join(fixture.root, "marker.txt"); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + backendWritingMarker( + marker, + `ctx.registry.provide("terminal.sessions", { live: true });`, + ), + ); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id); + + expect(consume("terminal.sessions")).toEqual({ live: true }); + + await loader.deactivate(plugin.id); + // Disabling the plugin must take its services with it, or a consumer + // would keep resolving a dead provider. + expect(consume("terminal.sessions")).toBeUndefined(); + }); + + it("detaches event listeners the plugin registered", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + const marker = path.join(fixture.root, "marker.txt"); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + backendWritingMarker(marker, `ctx.events.on("host.status", () => {});`), + ); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id); + + expect(pluginEvents.listenerCount("host.status")).toBe(1); + + await loader.deactivate(plugin.id); + expect(pluginEvents.listenerCount("host.status")).toBe(0); + }); + + it("reports an activation failure instead of leaving a half-live plugin", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + `export async function activate() { throw new Error("boom"); }`, + ); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + + await expect(loader.activate(plugin.id)).rejects.toThrow("boom"); + expect(plugin.state).toBe("crashed"); + expect(plugin.inProcess).toBeNull(); + expect(plugin.lastError).toContain("boom"); + }); + + it("rejects an entry with no activate export", async () => { + fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + `export const nothing = true;`, + ); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + + await expect(loader.activate(plugin.id)).rejects.toThrow( + /does not export an activate/, + ); + }); + + it("runs a first-party plugin in a worker when it does not ask for the tier", async () => { + fixture = createFixturePlugin({ + id: "ssh-terminal", + permissions: ["hosts.read"], + manifestOverrides: { category: "Terminal" }, + }); + + loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id, "user-1"); + + expect(plugin.state).toBe("active"); + expect(plugin.worker).not.toBeNull(); + expect(plugin.inProcess).toBeNull(); + }); +}); diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts index ff2d15144..bb5c5f47c 100644 --- a/src/backend/tests/plugins/loader.test.ts +++ b/src/backend/tests/plugins/loader.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js"; import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; @@ -280,6 +280,24 @@ describe("PluginLoader", () => { }); describe("loadAll", () => { + // loadAll scans bundled plugins as well as user-installed ones, and the + // repo really does ship one now. These tests are about the user directory, + // so they point the bundled dir somewhere empty. + let previousBundled: string | undefined; + + beforeEach(() => { + previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR; + process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins"; + }); + + afterEach(() => { + if (previousBundled === undefined) { + delete process.env.TERMIX_BUNDLED_PLUGINS_DIR; + } else { + process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled; + } + }); + it("returns an empty list when the plugins dir does not exist", async () => { const previous = process.env.DATA_DIR; process.env.DATA_DIR = "/nonexistent/data-dir"; diff --git a/src/backend/tests/plugins/registry.test.ts b/src/backend/tests/plugins/registry.test.ts new file mode 100644 index 000000000..0574b8013 --- /dev/null +++ b/src/backend/tests/plugins/registry.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearRegistry, + consume, + has, + provide, + revoke, +} from "../../plugins/registry.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +describe("service registry", () => { + afterEach(() => clearRegistry()); + + it("round-trips a provider", () => { + const pool = { name: "ssh-pool" }; + provide("ssh.transport", pool); + + expect(has("ssh.transport")).toBe(true); + expect(consume("ssh.transport")).toBe(pool); + }); + + it("returns undefined rather than throwing for a missing provider", () => { + // Callers have to cope with a provider disappearing when its plugin is + // disabled, so this is the contract rather than an error. + expect(consume("nothing.here")).toBeUndefined(); + expect(has("nothing.here")).toBe(false); + }); + + it("replaces an existing provider", () => { + provide("ssh.transport", { id: 1 }); + provide("ssh.transport", { id: 2 }); + expect(consume<{ id: number }>("ssh.transport")?.id).toBe(2); + }); + + it("only revokes the value that is actually registered", () => { + const first = { id: 1 }; + const second = { id: 2 }; + + provide("terminal.sessions", first); + provide("terminal.sessions", second); + + // A crashed-and-restarted plugin must not revoke the replacement its own + // restart installed. + expect(revoke("terminal.sessions", first)).toBe(false); + expect(consume("terminal.sessions")).toBe(second); + + expect(revoke("terminal.sessions", second)).toBe(true); + expect(consume("terminal.sessions")).toBeUndefined(); + }); + + it("revokes unconditionally when no value is given", () => { + provide("terminal.sessions", { id: 1 }); + expect(revoke("terminal.sessions")).toBe(true); + expect(has("terminal.sessions")).toBe(false); + }); + + it("reports a revoke of something never registered", () => { + expect(revoke("nothing.here")).toBe(false); + }); +}); diff --git a/src/backend/tests/plugins/worker-deactivate.test.ts b/src/backend/tests/plugins/worker-deactivate.test.ts new file mode 100644 index 000000000..541de0e61 --- /dev/null +++ b/src/backend/tests/plugins/worker-deactivate.test.ts @@ -0,0 +1,121 @@ +/** + * Worker plugins gained an optional deactivate() hook. It is best-effort by + * design: a plugin that ignores it or hangs must not be able to block a + * disable, it just loses its cleanup. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { PluginLoader } from "../../plugins/loader.js"; +import { PluginBroker } from "../../plugins/broker.js"; +import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +function buildLoader(broker: PluginBroker): PluginLoader { + return new PluginLoader({ + onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), + onWorkerGone: (plugin) => broker.detach(plugin.id), + onBeforeTerminate: async (plugin) => { + const runtime = broker.get(plugin.id); + if (runtime) await broker.requestDeactivate(runtime, 2000); + }, + }); +} + +function newBroker(): PluginBroker { + return new PluginBroker({ + listHosts: async () => [], + resolveHost: async () => null, + }); +} + +describe("worker deactivate hook", () => { + let fixture: Fixture | null = null; + let loader: PluginLoader | null = null; + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + fixture?.cleanup(); + fixture = null; + }); + + it("runs the plugin's deactivate() before the worker is terminated", async () => { + fixture = createFixturePlugin(); + const marker = path.join(fixture.root, "marker.txt"); + + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + ` +import fs from "node:fs"; +export async function activate() {} +export async function deactivate() { + fs.writeFileSync(${JSON.stringify(marker)}, "cleaned"); +} +`, + ); + + loader = buildLoader(newBroker()); + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id, "user-1"); + await loader.deactivate(plugin.id); + + expect(plugin.state).toBe("stopped"); + expect(fs.readFileSync(marker, "utf8")).toBe("cleaned"); + }); + + it("still stops a plugin that exports no deactivate", async () => { + fixture = createFixturePlugin(); + loader = buildLoader(newBroker()); + + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id, "user-1"); + await loader.deactivate(plugin.id); + + expect(plugin.state).toBe("stopped"); + expect(plugin.worker).toBeNull(); + }); + + it("does not let a hanging deactivate block the disable", async () => { + fixture = createFixturePlugin(); + fs.writeFileSync( + path.join(fixture.dir, "backend", "index.mjs"), + ` +export async function activate() {} +export function deactivate() { + return new Promise(() => {}); +} +`, + ); + + const broker = newBroker(); + loader = new PluginLoader({ + onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), + onWorkerGone: (plugin) => broker.detach(plugin.id), + onBeforeTerminate: async (plugin) => { + const runtime = broker.get(plugin.id); + // Short timeout so the test does not wait out the real one. + if (runtime) await broker.requestDeactivate(runtime, 150); + }, + }); + + const plugin = await loader.load(fixture.dir); + await loader.activate(plugin.id, "user-1"); + + const started = Date.now(); + await loader.deactivate(plugin.id); + + expect(plugin.state).toBe("stopped"); + expect(Date.now() - started).toBeLessThan(2000); + }); +}); diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index d0118de00..eed5084f4 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -372,6 +372,7 @@ export type AdminSection = | "api-keys" | "audit-log" | "ssl" + | "plugins" | "touch-input"; export type ThemeId = | "dark" diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 13f398789..0015130d0 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -217,6 +217,7 @@ import { serializeSplitTabs, type PersistedSplitTab, } from "@/shell/splitTabUtils"; +import { isTabTypeAvailable, refreshPluginState } from "@/shell/pluginLoader"; export { buildHostTree } from "@/sidebar/build-host-tree"; export { tabIcon, renderTabContent } from "@/shell/tabUtils"; @@ -458,6 +459,12 @@ export function AppShell({ .catch(() => setIsAdmin(false)); }, []); + // Plugin state gates which tab types can be opened, so it has to land before + // a saved session is restored. A failure leaves everything enabled. + useEffect(() => { + refreshPluginState().catch(() => {}); + }, []); + const handleDismissDonationModal = useCallback(() => { setShowDonationModal(false); dismissDonationModal().catch(() => {}); @@ -1657,6 +1664,11 @@ export function AppShell({ }, options?: { endpointId?: string; label?: string; forceNewTab?: boolean }, ) { + // A tab type owned by a disabled plugin cannot be opened, including from a + // restored session. Guarded here rather than at each call site because + // every path into a tab goes through this one. + if (!isTabTypeAvailable(type)) return null; + if (!restore && !options?.forceNewTab) { const existing = tabsRef.current.find( (t) => diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts new file mode 100644 index 000000000..75a90cda8 --- /dev/null +++ b/src/ui/api/plugins-api.ts @@ -0,0 +1,38 @@ +import { rbacApi } from "@/main-axios"; + +export interface PluginTabContribution { + id: string; + titleKey: string; + icon: string; + openFrom: string[]; +} + +export interface PluginContributions { + tabs?: PluginTabContribution[]; +} + +export interface PluginSummary { + id: string; + name: string; + version: string; + tier: string; + source: string; + enabled: boolean; + runtimeState: string; + lastError: string | null; + contributes: PluginContributions | null; +} + +export async function getPlugins(): Promise { + const response = await rbacApi.get("/plugins"); + return Array.isArray(response.data) ? response.data : []; +} + +export async function setPluginEnabled( + pluginId: string, + enabled: boolean, +): Promise { + await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, { + enabled, + }); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 4ff6c41dc..52f47a8a2 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3977,7 +3977,14 @@ "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", "updateAiEnabledFailed": "Could not update the AI setting", - "updateAiEndpointsFailed": "Could not update the allowed hosts" + "updateAiEndpointsFailed": "Could not update the allowed hosts", + "plugins": "Plugins", + "pluginsNone": "No plugins installed.", + "pluginsLoadFailed": "Failed to load plugins", + "pluginToggleFailed": "Failed to change the plugin", + "pluginBuiltIn": "BUILT IN", + "pluginEnabled": "{{name}} enabled", + "pluginDisabled": "{{name}} disabled" }, "newUi": { "sidebar": { diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index b73a5b7a3..3b86db22e 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -46,6 +46,7 @@ import { canEditHost } from "@/sidebar/host-permissions"; import { RAIL_ITEMS, RAIL_UTILITY_ITEMS } from "@/sidebar/rail-items"; import { useAiAvailability } from "@/hooks/use-ai-availability"; import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx"; +import { isTabTypeAvailable } from "@/shell/pluginLoader"; interface CommandPaletteProps { isOpen: boolean; @@ -87,11 +88,12 @@ function getSshActions(host: Host): { }[] { const metricsEnabled = host.statsConfig?.metricsEnabled !== false; return [ - host.enableTerminal !== false && { - type: "terminal", - icon: Terminal, - label: "Terminal", - }, + host.enableTerminal !== false && + isTabTypeAvailable("terminal") && { + type: "terminal", + icon: Terminal, + label: "Terminal", + }, host.enableFileManager && { type: "files", icon: FolderSearch, diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts new file mode 100644 index 000000000..274faf1a2 --- /dev/null +++ b/src/ui/shell/pluginLoader.ts @@ -0,0 +1,87 @@ +/** + * Applies backend plugin state to the shell. + * + * Two kinds of contributed surface exist, and they are removed differently: + * + * - A tab that only exists because a plugin registered it goes away with + * unregisterTabComponent / unregisterRailItem. + * - A tab that ships as a built-in code path but is OWNED by a plugin (the + * terminal) cannot, because it was never registered. Callers ask + * isTabTypeAvailable() before offering it instead. + * + * The terminal is in the second group because its tab takes around a dozen + * props (host, split-pane state, file-manager and editor callbacks) that the + * generic { tab } plugin surface has no way to supply. Rewiring that was a + * bigger regression risk to a working terminal than gating the entry points. + */ + +import { unregisterRailItem } from "@/sidebar/rail-items"; +import { unregisterTabComponent } from "@/shell/tabUtils"; +import { getPlugins, type PluginSummary } from "@/api/plugins-api"; + +/** + * Built-in tab types owned by a plugin, keyed by plugin id. The terminal has + * no rail item: it opens from a host, the command palette or quick connect. + */ +const BUILT_IN_TABS_BY_PLUGIN: Record = { + "ssh-terminal": ["terminal"], +}; + +let pluginEnabled = new Map(); +let disabledTabTypes = new Set(); + +export function applyPluginState(plugins: PluginSummary[]): void { + const nextEnabled = new Map(); + const nextDisabledTabs = new Set(); + + for (const plugin of plugins) { + nextEnabled.set(plugin.id, plugin.enabled); + + const builtInTabs = BUILT_IN_TABS_BY_PLUGIN[plugin.id]; + if (builtInTabs) { + if (!plugin.enabled) { + for (const tabId of builtInTabs) nextDisabledTabs.add(tabId); + } + continue; + } + + // Plugin-registered surfaces: only removal happens here. Registration is + // the plugin's own frontend entry being imported. + if (!plugin.enabled) { + for (const tab of plugin.contributes?.tabs ?? []) { + unregisterRailItem(tab.id); + unregisterTabComponent(tab.id); + } + } + } + + pluginEnabled = nextEnabled; + disabledTabTypes = nextDisabledTabs; +} + +export async function refreshPluginState(): Promise { + const plugins = await getPlugins(); + applyPluginState(plugins); + return plugins; +} + +/** + * Whether a built-in tab type may still be opened. Call sites that offer a + * connection action (host item, command palette, quick connect) check this so + * a disabled plugin stops offering the tab it owns. + */ +export function isTabTypeAvailable(tabType: string): boolean { + return !disabledTabTypes.has(tabType); +} + +export function isPluginEnabled(pluginId: string): boolean { + // Unknown means not yet loaded; assume enabled so the UI does not blank out + // a working feature while the first request is in flight. + return pluginEnabled.get(pluginId) ?? true; +} + +/** Test seam. */ +export function resetPluginState(): void { + pluginEnabled = new Map(); + disabledTabTypes = new Set(); +} diff --git a/src/ui/sidebar/AdminPluginsSection.tsx b/src/ui/sidebar/AdminPluginsSection.tsx new file mode 100644 index 000000000..6fc452da6 --- /dev/null +++ b/src/ui/sidebar/AdminPluginsSection.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Puzzle } from "lucide-react"; +import { toast } from "sonner"; +import { SettingRow } from "@/components/section-card"; +import { + getPlugins, + setPluginEnabled, + type PluginSummary, +} from "@/api/plugins-api"; +import { refreshPluginState } from "@/shell/pluginLoader"; +import { AccordionSection, AdminToggle } from "./AdminSettingsShared"; + +export function AdminPluginsSection({ + open, + onToggle, +}: { + open: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + const [plugins, setPlugins] = useState([]); + const [busy, setBusy] = useState(null); + const [loaded, setLoaded] = useState(false); + + const load = useCallback(async () => { + try { + setPlugins(await getPlugins()); + } catch { + toast.error(t("admin.pluginsLoadFailed")); + } finally { + setLoaded(true); + } + }, [t]); + + useEffect(() => { + if (open && !loaded) void load(); + }, [open, loaded, load]); + + const toggle = async (plugin: PluginSummary) => { + setBusy(plugin.id); + try { + await setPluginEnabled(plugin.id, !plugin.enabled); + await load(); + // Re-read state so the shell picks the change up without a reload. + await refreshPluginState(); + toast.success( + plugin.enabled + ? t("admin.pluginDisabled", { name: plugin.name }) + : t("admin.pluginEnabled", { name: plugin.name }), + ); + } catch { + toast.error(t("admin.pluginToggleFailed")); + } finally { + setBusy(null); + } + }; + + return ( + } + open={open} + onToggle={onToggle} + > + {plugins.length === 0 ? ( +

+ {loaded ? t("admin.pluginsNone") : t("common.loading")} +

+ ) : ( + plugins.map((plugin) => ( + + void toggle(plugin)} + disabled={busy === plugin.id} + /> + + )) + )} +
+ ); +} diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 8cbc767c1..426d8e0cd 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -94,6 +94,7 @@ import { type UserRole, } from "@/main-axios"; import { type AdminSection, type Host } from "@/types/ui-types"; +import { AdminPluginsSection } from "./AdminPluginsSection"; import { AdminRolesSection, AdminSessionsSection, @@ -1496,6 +1497,11 @@ export function AdminSettingsPanel({ users={users} /> + toggle("plugins")} + /> + toggle("touch-input")} diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index a5d1b7c48..8d3d633d3 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -88,6 +88,7 @@ import { } from "@/lib/local-adaptive-preferences"; import type { WebEndpoint } from "@/types/index"; import { openWebEndpointExternally } from "@/api/web-endpoint-api"; +import { isTabTypeAvailable } from "@/shell/pluginLoader"; export function statusCheckEnabled(host: Host): boolean { return host.statsConfig?.statusCheckEnabled !== false; @@ -149,7 +150,8 @@ export function getSshActions(host: Host): { const connectionActions = [ host.enableSsh && - host.enableTerminal && { + host.enableTerminal && + isTabTypeAvailable("terminal") && { type: "terminal" as TabType, icon: Terminal, label: "Terminal", diff --git a/src/ui/tests/shell/pluginLoader.test.ts b/src/ui/tests/shell/pluginLoader.test.ts new file mode 100644 index 000000000..b9f89a10e --- /dev/null +++ b/src/ui/tests/shell/pluginLoader.test.ts @@ -0,0 +1,147 @@ +/** + * Disabling a plugin has to remove the surfaces it owns, and disabling one + * plugin must not touch another's. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Puzzle } from "lucide-react"; +import { + applyPluginState, + isPluginEnabled, + isTabTypeAvailable, + resetPluginState, +} from "@/shell/pluginLoader"; +import { + registerRailItem, + unregisterRailItem, + visibleRailItems, +} from "@/sidebar/rail-items"; +import type { PluginSummary } from "@/api/plugins-api"; + +vi.mock("@/api/plugins-api", () => ({ + getPlugins: vi.fn().mockResolvedValue([]), + setPluginEnabled: vi.fn(), +})); + +function plugin(overrides: Partial = {}): PluginSummary { + return { + id: "ssh-terminal", + name: "SSH Terminal", + version: "1.0.0", + tier: "first-party", + source: "bundled", + enabled: true, + runtimeState: "active", + lastError: null, + contributes: { + tabs: [ + { + id: "terminal", + titleKey: "nav.terminal", + icon: "SquareTerminal", + openFrom: ["host-context-menu", "palette"], + }, + ], + }, + ...overrides, + }; +} + +describe("plugin state applied to the shell", () => { + afterEach(() => { + resetPluginState(); + unregisterRailItem("other-plugin-tab"); + }); + + it("allows terminal tabs while the plugin is enabled", () => { + applyPluginState([plugin({ enabled: true })]); + expect(isTabTypeAvailable("terminal")).toBe(true); + }); + + it("blocks terminal tabs when the plugin is disabled", () => { + applyPluginState([plugin({ enabled: false })]); + expect(isTabTypeAvailable("terminal")).toBe(false); + }); + + it("allows them again when the plugin is re-enabled", () => { + applyPluginState([plugin({ enabled: false })]); + expect(isTabTypeAvailable("terminal")).toBe(false); + + applyPluginState([plugin({ enabled: true })]); + expect(isTabTypeAvailable("terminal")).toBe(true); + }); + + it("leaves unrelated tab types alone", () => { + applyPluginState([plugin({ enabled: false })]); + + // Disabling the terminal must not take the rest of the app with it. + for (const type of ["files", "docker", "tunnel", "rdp", "host-metrics"]) { + expect(isTabTypeAvailable(type)).toBe(true); + } + }); + + it("leaves another plugin's rail item alone when the terminal is disabled", () => { + registerRailItem({ + id: "other-plugin-tab", + icon: Puzzle, + labelKey: "nav.otherPlugin", + kind: "tab", + }); + + applyPluginState([ + plugin({ enabled: false }), + plugin({ + id: "other-plugin", + name: "Other", + tier: "community", + source: "registry", + enabled: true, + contributes: null, + }), + ]); + + expect(isTabTypeAvailable("terminal")).toBe(false); + // The core ask: disabling one plugin must not disturb another. + expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab"); + }); + + it("unregisters a plugin-registered tab when that plugin is disabled", () => { + registerRailItem({ + id: "other-plugin-tab", + icon: Puzzle, + labelKey: "nav.otherPlugin", + kind: "tab", + }); + expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab"); + + applyPluginState([ + plugin({ + id: "other-plugin", + name: "Other", + enabled: false, + contributes: { + tabs: [ + { + id: "other-plugin-tab", + titleKey: "nav.otherPlugin", + icon: "Puzzle", + openFrom: ["rail"], + }, + ], + }, + }), + ]); + + expect(visibleRailItems().map((i) => i.id)).not.toContain( + "other-plugin-tab", + ); + }); + + it("reports enablement, defaulting to true before the first load", () => { + expect(isPluginEnabled("ssh-terminal")).toBe(true); + + applyPluginState([plugin({ enabled: false })]); + expect(isPluginEnabled("ssh-terminal")).toBe(false); + expect(isPluginEnabled("never-heard-of-it")).toBe(true); + }); +});