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

+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);
});
});