mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user