feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
LukeGus committed 2026-09-21 08:28:50 -05:00
1 parent e040b12045
commit c94a453f1e
43 files changed
+2426 -38

No files matched your search

+3
View File
@@ -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
+9
View File
@@ -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;
+9
View File
@@ -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;
+1
View File
@@ -8,6 +8,7 @@
"asar": true,
"asarUnpack": [
"dist/backend/**/*",
"dist/plugins/**/*",
"node_modules/**/*",
"public/icons/**/*",
"public/icon.*"
+3 -3
View File
@@ -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",
+70
View File
@@ -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.
+83
View File
@@ -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;
}
+41
View File
@@ -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);
}
+30
View File
@@ -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": []
}
+33
View File
@@ -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(", ")}`);
+1
View File
@@ -119,6 +119,7 @@
"notify.send",
"users.read",
"process.sidecar",
"process:transport-owner",
"ui.tab",
"ui.rail",
"ui.card",
+2
View File
@@ -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 = [
+151
View File
@@ -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;
+69 -4
View File
@@ -139,10 +139,25 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
const userConnections = new Map<string, Set<WebSocket>>();
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<void> {
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<void>((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<void> {
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<void>((resolve) => server.close(() => resolve()));
sshLogger.info("Terminal WebSocket server stopped", {
operation: "terminal_ws_stop",
});
}
/**
* Auth path for anonymous share-link guests (?shareToken=<linkToken>).
* Never touches DataCrypto/user credentials - guests join an already-live
+36
View File
@@ -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<void> {
if (runtime.disposed) return Promise.resolve();
const replyTo = this.nextPushId++;
return new Promise<void>((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;
+58
View File
@@ -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<string> = 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)
);
}
+173
View File
@@ -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<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
};
registry: {
provide: <T>(key: string, value: T) => void;
consume: <T>(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<void>;
deactivate?: () => void | Promise<void>;
}
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<void> {
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" },
);
}
}
}
+68 -1
View File
@@ -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<void> {
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<LoadedPlugin[]> {
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<LoadedPlugin[]> {
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<LoadedPlugin[]> {
export async function activatePlugin(
pluginId: string,
ownerUserId: string,
ownerUserId?: string,
): Promise<void> {
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
await pluginLoader.activate(pluginId, ownerUserId);
+174 -20
View File
@@ -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<void>;
/** 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<LoadedPlugin[]> {
const root = getPluginsDir();
if (!fs.existsSync(root)) return [];
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const loaded: LoadedPlugin[] = [];
const seen = new Set<string>();
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<void> {
/**
* `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<void> {
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<void> {
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<void> {
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<void> {
// 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<string, string> {
return env;
}
function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
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}`;
+20
View File
@@ -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: [] };
}
+33
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+59
View File
@@ -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<string, unknown>();
export function provide<T>(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<T>(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();
}
+24
View File
@@ -60,6 +60,21 @@ function handleResponse(message: PluginResponse): void {
}
async function handlePush(push: PluginPush): Promise<void> {
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<void>) | null = null;
async function start(): Promise<void> {
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" });
+2 -1
View File
@@ -295,7 +295,8 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
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");
@@ -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<boolean> {
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);
});
});
@@ -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<string, unknown> {
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/);
});
});
@@ -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<boolean> {
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);
});
});
@@ -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<string, unknown> = {}) {
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");
});
});
@@ -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();
});
});
+19 -1
View File
@@ -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";
@@ -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<typeof pool>("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);
});
});
@@ -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);
});
});
+1
View File
@@ -372,6 +372,7 @@ export type AdminSection =
| "api-keys"
| "audit-log"
| "ssl"
| "plugins"
| "touch-input";
export type ThemeId =
| "dark"
+12
View File
@@ -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) =>
+38
View File
@@ -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<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, {
enabled,
});
}
+8 -1
View File
@@ -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": {
+7 -5
View File
@@ -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,
+87
View File
@@ -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<string, string[]> = {
"ssh-terminal": ["terminal"],
};
let pluginEnabled = new Map<string, boolean>();
let disabledTabTypes = new Set<string>();
export function applyPluginState(plugins: PluginSummary[]): void {
const nextEnabled = new Map<string, boolean>();
const nextDisabledTabs = new Set<string>();
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<PluginSummary[]> {
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();
}
+96
View File
@@ -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<PluginSummary[]>([]);
const [busy, setBusy] = useState<string | null>(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 (
<AccordionSection
label={t("admin.plugins")}
icon={<Puzzle className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{plugins.length === 0 ? (
<p className="text-xs text-muted-foreground py-3">
{loaded ? t("admin.pluginsNone") : t("common.loading")}
</p>
) : (
plugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
}
>
<AdminToggle
on={plugin.enabled}
onToggle={() => void toggle(plugin)}
disabled={busy === plugin.id}
/>
</SettingRow>
))
)}
</AccordionSection>
);
}
+6
View File
@@ -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}
/>
<AdminPluginsSection
open={openSections.has("plugins")}
onToggle={() => toggle("plugins")}
/>
<AdminTouchInputSection
open={openSections.has("touch-input")}
onToggle={() => toggle("touch-input")}
+3 -1
View File
@@ -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",
+147
View File
@@ -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> = {}): 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);
});
});