mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
e040b12045
commit
c94a453f1e
43 files changed
+2426
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"dist/backend/**/*",
|
||||
"dist/plugins/**/*",
|
||||
"node_modules/**/*",
|
||||
"public/icons/**/*",
|
||||
"public/icon.*"
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
@@ -119,6 +119,7 @@
|
||||
"notify.send",
|
||||
"users.read",
|
||||
"process.sidecar",
|
||||
"process:transport-owner",
|
||||
"ui.tab",
|
||||
"ui.rail",
|
||||
"ui.card",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -372,6 +372,7 @@ export type AdminSection =
|
||||
| "api-keys"
|
||||
| "audit-log"
|
||||
| "ssl"
|
||||
| "plugins"
|
||||
| "touch-input";
|
||||
export type ThemeId =
|
||||
| "dark"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user