feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

-620
View File
@@ -1,620 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
auditEntries: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async (params: Record<string, unknown>) => {
state.auditEntries.push(params);
}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/**
* A host as resolveHostById would return it: secrets in plaintext. The tests
* assert none of this reaches the worker.
*/
const SECRET = "hunter2-plaintext-secret";
const RAW_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
// None of these may ever cross the boundary.
password: SECRET,
key: SECRET,
keyPassword: SECRET,
certPublicKey: SECRET,
sudoPassword: SECRET,
autostartPassword: SECRET,
};
describe("PluginBroker", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [];
state.auditEntries = [];
storage = memoryStorage();
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
// The bus is a module singleton, so a leftover listener would leak between
// tests.
const { pluginEvents } = await import("../../plugins/events.js");
pluginEvents.clear();
while (fixtures.length) fixtures.pop()!.cleanup();
});
function grant(capability: string, pluginId = "sample-plugin") {
state.grants.push({ pluginId, capability });
invalidatePluginPermissionCache(pluginId);
}
/**
* Boots a real worker whose activate() runs `body` and reports the outcome
* back through a ctx.log call the test can read.
*/
/** In-memory stand-in for PluginStorageRepository, scoped per plugin. */
function memoryStorage() {
const rows = new Map<string, string>();
const at = (pluginId: string, key: string) => `${pluginId}\u0000${key}`;
return {
rows,
backend: {
get: async (pluginId: string, key: string) =>
rows.get(at(pluginId, key)) ?? null,
set: async (pluginId: string, key: string, value: string) => {
rows.set(at(pluginId, key), value);
},
delete: async (pluginId: string, key: string) =>
rows.delete(at(pluginId, key)),
listKeys: async (pluginId: string) =>
[...rows.keys()]
.filter((k) => k.startsWith(`${pluginId}\u0000`))
.map((k) => k.split("\u0000")[1]),
},
};
}
let storage = memoryStorage();
async function boot(body: string, permissions?: string[]) {
const fixture = createFixturePlugin({
permissions: permissions ?? ["hosts.read", "storage.own"],
backendSource: `
export async function activate(ctx) {
globalThis.__result = undefined;
try {
globalThis.__result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
globalThis.__result = { ok: false, error: error.message };
}
await ctx.log.info(JSON.stringify(globalThis.__result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RAW_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RAW_HOST : null),
storage: storage.backend,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result reported" };
}
describe("permission gate", () => {
it(
"allows a call whose capability is declared and granted",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(result.value).toMatchObject({ id: 42, name: "prod-box" });
},
WORKER_TIMEOUT,
);
it(
"denies a declared capability that was never granted",
async () => {
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"denies a granted capability the manifest never declared",
async () => {
// Granted in the database, but absent from the manifest: widening a
// plugin's reach must require a new manifest, not just a grant.
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`, [
"storage.own",
]);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"rejects an unknown ctx method",
async () => {
const result = await boot(
`return await new Promise((resolve, reject) => {
ctx.hosts.__proto__; resolve("noop");
});`,
);
expect(result.ok).toBe(true);
},
WORKER_TIMEOUT,
);
});
describe("credential isolation", () => {
it(
"never sends credential material to the worker",
async () => {
grant("hosts.read");
const result = await boot(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const blob = JSON.stringify({ host, list });
if (blob.includes(${JSON.stringify(SECRET)})) {
throw new Error("SECRET LEAKED: " + blob);
}
return { keys: Object.keys(host).sort(), listLength: list.length };
`);
expect(result.ok).toBe(true);
expect(result.value.listLength).toBe(1);
expect(result.value.keys).not.toContain("password");
expect(result.value.keys).not.toContain("key");
expect(result.value.keys).not.toContain("keyPassword");
expect(result.value.keys).not.toContain("certPublicKey");
expect(result.value.keys).not.toContain("sudoPassword");
expect(result.value.keys).not.toContain("autostartPassword");
},
WORKER_TIMEOUT,
);
it(
"returns null rather than distinguishing missing from forbidden",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(999);`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
});
describe("audit", () => {
it(
"writes one plugin-attributed line per permitted call",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toBeDefined();
expect(entry).toMatchObject({
userId: "user-1",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
},
WORKER_TIMEOUT,
);
it(
"audits a denied call as a failure",
async () => {
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toMatchObject({
username: "plugin:sample-plugin",
success: false,
});
expect(String(entry?.errorMessage)).toContain("is not granted");
},
WORKER_TIMEOUT,
);
it(
"does not let a plugin forge the actor or smuggle bulk details",
async () => {
grant("hosts.read");
// ctx.log takes a context object, so this exercises the summariser on
// an argument a plugin genuinely controls.
await boot(`
await ctx.log.info("hello", { username: "admin", userId: "user-999" });
return await ctx.hosts.get(42);
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
// Attribution is broker-set regardless of what the plugin passed.
expect(entry?.username).toBe("plugin:sample-plugin");
expect(entry?.userId).toBe("user-1");
// No audit entry anywhere claims the user the plugin named.
expect(
state.auditEntries.some(
(e) => e.userId === "user-999" || e.username === "admin",
),
).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"summarises rather than records a long string argument",
async () => {
grant("hosts.read");
await boot(`
try { await ctx.hosts.get("${"x".repeat(500)}"); } catch {}
return "done";
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
const details = String(entry?.details);
// Capped, so a plugin cannot write bulk data into the audit trail.
expect(details.length).toBeLessThan(200);
expect(details).not.toContain("x".repeat(200));
},
WORKER_TIMEOUT,
);
});
describe("ctx.storage", () => {
it(
"round-trips a structured value",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("prefs", { theme: "dark", count: 3 });
const read = await ctx.storage.get("prefs");
const keys = await ctx.storage.list();
return { read, keys };
`);
expect(result.ok).toBe(true);
expect(result.value.read).toEqual({ theme: "dark", count: 3 });
expect(result.value.keys).toEqual(["prefs"]);
},
WORKER_TIMEOUT,
);
it(
"returns null for a key that was never set",
async () => {
grant("storage.own");
const result = await boot(`return await ctx.storage.get("nope");`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
it(
"deletes a key",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("temp", 1);
await ctx.storage.delete("temp");
return await ctx.storage.list();
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"scopes writes to the calling plugin, which cannot name another scope",
async () => {
grant("storage.own");
// A plugin only ever supplies a key; the plugin id is added by the
// broker, so there is no argument through which to reach another
// plugin's rows.
await boot(`await ctx.storage.set("owned", "value");`);
const keys = [...storage.rows.keys()];
expect(keys).toEqual(["sample-plugin\u0000owned"]);
},
WORKER_TIMEOUT,
);
it(
"denies storage without the granted capability",
async () => {
const result = await boot(`return await ctx.storage.get("prefs");`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "storage.own"');
},
WORKER_TIMEOUT,
);
it(
"rejects an empty key and an oversized value",
async () => {
grant("storage.own");
const result = await boot(`
const errors = [];
try { await ctx.storage.set("", 1); } catch (e) { errors.push(e.message); }
try {
await ctx.storage.set("big", "x".repeat(300000));
} catch (e) { errors.push(e.message); }
return errors;
`);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain("non-empty key");
expect(result.value[1]).toContain("byte limit");
},
WORKER_TIMEOUT,
);
});
describe("ctx.schedule", () => {
it(
"registers a timer and fires the plugin's handler",
async () => {
grant("storage.own");
const result = await boot(`
await new Promise(async (resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timer never fired")), 8000);
await ctx.schedule.every(1000, async () => {
clearTimeout(timer);
resolve();
});
});
return "fired";
`);
expect(result.ok).toBe(true);
expect(result.value).toBe("fired");
},
WORKER_TIMEOUT,
);
it(
"rejects an interval below the floor",
async () => {
grant("storage.own");
const result = await boot(
`return await ctx.schedule.every(5, () => {});`,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("at least 1000ms");
},
WORKER_TIMEOUT,
);
it(
"cancel stops the timer and drops it from the runtime",
async () => {
grant("storage.own");
await boot(`
const id = await ctx.schedule.every(1000, () => {});
await ctx.schedule.cancel(id);
`);
expect(broker!.get("sample-plugin")!.timers.size).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("ctx.events", () => {
it(
"pushes a subscribed topic through to the plugin's listener",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
// The plugin subscribes during activate, then reports what it sees on
// the next event. boot() resolves once activate returns, so the emit
// below happens with the subscription already live.
await boot(
`
await ctx.events.on("host.status", async (payload) => {
await ctx.log.info("EVENT:" + JSON.stringify(payload));
});
`,
["events.read"],
);
const runtime = broker!.get("sample-plugin")!;
expect(runtime.subscriptions.has("host.status")).toBe(true);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
const pushed: unknown[] = [];
const original = runtime.worker.postMessage.bind(runtime.worker);
runtime.worker.postMessage = ((message: unknown) => {
pushed.push(message);
return original(message);
}) as typeof runtime.worker.postMessage;
pluginEvents.emit("host.status", { hostId: 42, online: true });
expect(pushed).toContainEqual({
kind: "event",
key: "host.status",
payload: { hostId: 42, online: true },
});
},
WORKER_TIMEOUT,
);
it(
"refuses to emit outside the plugin's own namespace",
async () => {
grant("events.read");
const result = await boot(
`
const errors = [];
try {
await ctx.events.emit("host.status", { hostId: 1, online: false });
} catch (e) { errors.push(e.message); }
await ctx.events.emit("plugin.sample-plugin.ready", { ok: true });
return errors;
`,
["events.read"],
);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain(
'may only publish topics beginning with "plugin.sample-plugin."',
);
},
WORKER_TIMEOUT,
);
it(
"denies subscribing without the events.read capability",
async () => {
const result = await boot(
`return await ctx.events.on("host.status", () => {});`,
["events.read"],
);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "events.read"');
},
WORKER_TIMEOUT,
);
it(
"drops bus subscriptions when the plugin detaches",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
await boot(`await ctx.events.on("host.status", () => {});`, [
"events.read",
]);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
broker!.detach("sample-plugin");
expect(pluginEvents.listenerCount("host.status")).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("detach", () => {
it(
"releases ssh handles and timers when the worker goes away",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const runtime = broker!.get("sample-plugin");
expect(runtime).toBeDefined();
let released = false;
runtime!.sshHandles.set("sshconn:test", {
hostId: 42,
release: () => {
released = true;
},
});
runtime!.timers.set(
"t1",
setInterval(() => {}, 10_000),
);
broker!.detach("sample-plugin");
expect(released).toBe(true);
expect(broker!.get("sample-plugin")).toBeUndefined();
},
WORKER_TIMEOUT,
);
});
});
+93 -414
View File
@@ -1,8 +1,9 @@
/**
* The real bundled ssh-terminal plugin, as it ships.
* The bundled plugins, as they ship.
*
* 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.
* This reads plugins/ in the repo rather than a fixture: the manifests are
* checked-in artifacts, and a typo in one would otherwise only show up when
* the server tried to boot.
*/
import { describe, expect, it, vi } from "vitest";
@@ -10,11 +11,7 @@ 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";
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
@@ -26,443 +23,125 @@ vi.mock("../../utils/logger.js", () => ({
},
}));
function readBundledManifest(pluginId: string): Record<string, unknown> {
const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json");
return JSON.parse(fs.readFileSync(file, "utf8"));
const BUNDLED_DIR = getBundledPluginsDir();
function bundledIds(): string[] {
return fs
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
}
describe("bundled ssh-terminal plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "ssh-terminal");
function readManifest(pluginId: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
);
}
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);
const ids = bundledIds();
describe("bundled plugins", () => {
it("finds the bundled plugin directory", () => {
expect(ids.length).toBeGreaterThan(0);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("ssh-terminal"),
);
it.each(ids)("%s has a manifest that parses", (id) => {
const { manifest, errors } = parseManifest(readManifest(id));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("ssh-terminal");
expect(manifest?.category).toBe("Terminal");
expect(manifest?.id).toBe(id);
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
it.each(ids)("%s declares only catalog capabilities", (id) => {
const { manifest } = parseManifest(readManifest(id));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true);
for (const capability of manifest?.capabilities ?? []) {
expect(isKnownCapability(capability)).toBe(true);
}
});
it("contributes the terminal tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
const tab = manifest?.contributes?.tabs?.[0];
it.each(ids)("%s ships the entry points its manifest names", (id) => {
const { manifest } = parseManifest(readManifest(id));
const dir = path.join(BUNDLED_DIR, id);
// The shell keys tab content off this id, so it has to stay "terminal".
expect(tab?.id).toBe("terminal");
expect(tab?.openFrom).toContain("rail");
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
});
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");
// process:transport-owner was the v1 marker for the in-process tier. There
// is one tier now, so nothing should still be asking for it.
it.each(ids)("%s does not declare a v1 permission", (id) => {
const raw = readManifest(id);
// 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/);
expect(raw.permissions).toBeUndefined();
expect(raw.sidecars).toBeUndefined();
expect(JSON.stringify(raw)).not.toContain("transport-owner");
});
});
describe("bundled docker plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "docker");
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);
describe("declared dependencies", () => {
it("names a plugin that is actually bundled", () => {
for (const id of ids) {
const { manifest } = parseManifest(readManifest(id));
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
expect(ids).toContain(dependency);
}
}
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("docker"));
// plugins/ai/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
if (!ids.includes("ai")) return;
expect(errors).toEqual([]);
expect(manifest?.id).toBe("docker");
expect(manifest?.category).toBe("Infrastructure");
});
const { manifest } = parseManifest(readManifest("ai"));
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("docker")).toBe(true);
expect(runsInProcess("docker", manifest!.permissions)).toBe(true);
});
it("contributes the docker tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "docker".
expect(tab?.id).toBe("docker");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("declares the enableDocker host capability", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"docker",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(manifest?.dependencies?.automations).toBeTruthy();
});
});
describe("bundled host-metrics plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "host-metrics");
describe("plugins that own a port", () => {
// Every one of these had its server created at module scope at some point.
// The rule is that it belongs inside activate, or disable leaves the port
// held and re-enable fails.
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
"%s creates no listener at module scope",
(id) => {
if (!ids.includes(id)) return;
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);
});
const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs");
const source = fs.readFileSync(entry, "utf8");
const moduleScope = source.slice(
0,
source.indexOf("export async function activate"),
);
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("host-metrics"),
);
expect(moduleScope).not.toMatch(/\.listen\(/);
expect(moduleScope).not.toMatch(/new WebSocketServer/);
},
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("host-metrics");
expect(manifest?.category).toBe("Monitoring");
});
it("docker does not register a process signal handler", () => {
if (!ids.includes("docker")) return;
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
// every plugin's deactivate. A plugin calling process.exit() itself
// skipped the rest of that sequence.
for (const file of ["index.mjs", "console.ts"]) {
const candidate = path.join(BUNDLED_DIR, "docker", "backend", file);
if (!fs.existsSync(candidate)) continue;
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("host-metrics")).toBe(true);
expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true);
});
// Comments are stripped first: the rule is worth explaining in prose
// right where it applies, and that prose names the thing it forbids.
const code = fs
.readFileSync(candidate, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
it("contributes the host-metrics tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "host-metrics".
expect(tab?.id).toBe("host-metrics");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled remote-desktop plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "remote-desktop");
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("remote-desktop"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("remote-desktop");
expect(manifest?.category).toBe("Terminal");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true);
});
it("contributes the rdp/vnc/telnet tabs the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id);
expect(tabIds).toEqual(["rdp", "vnc", "telnet"]);
});
it("declares three host capabilities as an array", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const hostCapability = manifest?.contributes?.hostCapability;
expect(Array.isArray(hostCapability)).toBe(true);
expect(
(hostCapability as Array<{ key: string }>).map((entry) => entry.key),
).toEqual(["enableRdp", "enableVnc", "enableTelnet"]);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"remote-desktop",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled fleets plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "fleets");
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("fleets"));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("fleets");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("fleets"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("fleets")).toBe(true);
expect(runsInProcess("fleets", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"fleets",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled automations plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "automations");
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("automations"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("automations");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("automations"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("automations")).toBe(true);
expect(runsInProcess("automations", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"automations",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled network-topology plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "network-topology");
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("network-topology"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("network-topology");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("network-topology")).toBe(true);
expect(runsInProcess("network-topology", manifest!.permissions)).toBe(true);
});
it("contributes the network_graph tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "network_graph".
expect(tab?.id).toBe("network_graph");
expect(tab?.openFrom).toContain("rail");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"network-topology",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled workspaces plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "workspaces");
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("workspaces"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("workspaces");
expect(manifest?.category).toBe("Productivity");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("workspaces"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("workspaces")).toBe(true);
expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"workspaces",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled web-endpoint plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
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("web-endpoint"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("web-endpoint");
expect(manifest?.category).toBe("Networking");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
});
// The column this points at stays on core's ssh_data table, so a typo here
// would silently detach the host-editor checkbox from its storage.
it("contributes the enableWebUi host capability", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.contributes?.hostCapability).toMatchObject({
key: "enableWebUi",
});
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"web-endpoint",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
expect(code).not.toMatch(/process\.exit\(/);
}
});
});
@@ -0,0 +1,234 @@
/**
* The capability gate.
*
* Two conditions, both required: the manifest declares it and it is granted.
* Either missing is a refusal, and both the refusal and the allowed call are
* audited with the plugin id and the acting user, because "who did this"
* being answerable is the point of the gate.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const kvStore = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginStorageRepository: () => ({
get: async (pluginId: string, key: string) =>
kvStore.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId: string, key: string, value: string) => {
kvStore.set(`${pluginId}:${key}`, value);
},
delete: async (pluginId: string, key: string) =>
kvStore.delete(`${pluginId}:${key}`),
listKeys: async () => [...kvStore.keys()],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const { createPluginContext, createPluginHandle } =
await import("../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const { runAsActor } = await import("../../plugins/actor.js");
function manifest(capabilities: string[]) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as never;
}
function context(declared: string[]) {
const value = manifest(declared);
const handle = createPluginHandle("sample-plugin", {
activate: () => {},
});
return createPluginContext(value, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
kvStore.clear();
invalidatePluginPermissionCache();
});
describe("capability gate", () => {
it("denies a capability the manifest never declared", async () => {
// Granted in the database, but absent from the manifest. Widening a
// plugin's reach must always require a new manifest the user can see.
grants.set("sample-plugin", ["kv:own"]);
const ctx = context([]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("denies a declared capability that has not been granted", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("allows a capability that is both declared and granted", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await ctx.kv.set("colour", "blue");
await expect(ctx.kv.get("colour")).resolves.toBe("blue");
});
it("audits an allowed call with the plugin id and the actor", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await runAsActor("user-42", "request", () => ctx.kv.set("k", 1));
const entry = auditEntries.find((e) => e.action === "plugin_kv_set");
expect(entry).toMatchObject({
userId: "user-42",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
});
// A refused call is exactly the thing an operator wants to find later.
it("audits a denied call as a failure", async () => {
const ctx = context(["kv:own"]);
await expect(
runAsActor("user-42", "request", () => ctx.kv.get("k")),
).rejects.toThrow();
const entry = auditEntries.find((e) => e.action === "plugin_kv_get");
expect(entry).toMatchObject({ userId: "user-42", success: false });
expect(String(entry?.errorMessage)).toMatch(/not granted/);
});
it("picks up a grant made after the first denial", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("k")).rejects.toThrow();
grants.set("sample-plugin", ["kv:own"]);
invalidatePluginPermissionCache("sample-plugin");
await expect(ctx.kv.get("k")).resolves.toBeNull();
});
});
describe("ctx.asUser", () => {
it("makes the named user the actor and audits the switch", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
let seen: string | undefined;
await ctx.asUser("user-7", async () => {
seen = ctx.currentActor();
await ctx.kv.set("k", 1);
});
expect(seen).toBe("user-7");
expect(
auditEntries.find((e) => e.action === "plugin_as_user"),
).toMatchObject({ resourceId: "sample-plugin", success: true });
expect(
auditEntries.find((e) => e.action === "plugin_kv_set"),
).toMatchObject({ userId: "user-7" });
});
it("restores the previous actor afterwards", async () => {
const ctx = context([]);
await runAsActor("outer-user", "request", async () => {
await ctx.asUser("inner-user", async () => {
expect(ctx.currentActor()).toBe("inner-user");
});
expect(ctx.currentActor()).toBe("outer-user");
});
});
it("refuses an empty user id", async () => {
const ctx = context([]);
await expect(ctx.asUser("", async () => {})).rejects.toThrow(
/without a user id/,
);
});
});
describe("ctx.events namespacing", () => {
it("refuses a core topic without events:core", () => {
const ctx = context(["kv:own"]);
// Without this a plugin could publish host.status and drive the
// automations engine as though core had.
expect(() => ctx.events.emit("host.status", {})).toThrow(
/may only emit topics under/,
);
});
it("allows the plugin's own namespace", () => {
const ctx = context(["kv:own"]);
expect(() =>
ctx.events.emit("plugin.sample-plugin.thing", {}),
).not.toThrow();
});
it("allows a core topic when events:core is declared", () => {
const ctx = context(["events:core"]);
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
});
});
describe("ctx.kv limits", () => {
beforeEach(() => {
grants.set("sample-plugin", ["kv:own"]);
});
it("rejects an over-long key", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
/at most 128 characters/,
);
});
it("rejects a value over the size cap", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
/byte limit/,
);
});
});
@@ -1,365 +0,0 @@
/**
* The security test for the plugin runtime.
*
* The claim under test: a plugin worker cannot obtain plaintext credential
* material through ANY ctx method. Rather than assert field-by-field, this
* plants a unique sentinel string in every secret-bearing field of the resolved
* host and then asserts that the sentinel appears in nothing the worker ever
* receives -- return values, thrown error messages, or the ctx object itself.
*
* Every message crossing the boundary is recorded, so the assertion covers the
* wire, not just what the plugin happened to look at.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const SENTINEL = "SENT1NEL-plaintext-credential-do-not-leak";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
/** Every message the main thread sent to a worker. */
outbound: [] as unknown[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/** A host exactly as resolveHostById hands it over: secrets in the clear. */
const RESOLVED_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
password: SENTINEL,
key: SENTINEL,
keyPassword: SENTINEL,
certPublicKey: SENTINEL,
sudoPassword: SENTINEL,
autostartPassword: SENTINEL,
autostartKey: SENTINEL,
autostartKeyPassword: SENTINEL,
credentialId: 7,
vaultProfileId: 3,
};
function deepContains(value: unknown, needle: string): boolean {
if (typeof value === "string") return value.includes(needle);
if (value === null || typeof value !== "object") return false;
return Object.values(value as Record<string, unknown>).some((inner) =>
deepContains(inner, needle),
);
}
describe("plugin credential isolation", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [
{ pluginId: "sample-plugin", capability: "hosts.read" },
{ pluginId: "sample-plugin", capability: "ssh.exec" },
{ pluginId: "sample-plugin", capability: "storage.own" },
];
state.outbound = [];
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
/** Boots a plugin whose activate() runs `body` and reports the result. */
async function attack(body: string, sshOpenFails = false) {
const fixture = createFixturePlugin({
permissions: ["hosts.read", "ssh.exec", "storage.own"],
backendSource: `
export async function activate(ctx) {
let result;
try {
result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
result = { ok: false, error: String(error && error.message) };
}
await ctx.log.info(JSON.stringify(result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RESOLVED_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RESOLVED_HOST : null),
ssh: {
open: async () => {
if (sshOpenFails) {
// A failing connect must not spill the credential into the error
// message either: this mirrors ssh2 embedding config on failure.
throw new Error(
`Authentication failed for root using password ${SENTINEL}`,
);
}
return {
release: () => {},
exec: async () => ({
stdout: "uptime: 4 days",
stderr: "",
code: 0,
}),
};
},
},
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => {
// Record everything the main thread pushes at the worker.
const original = worker.postMessage.bind(worker);
worker.postMessage = ((message: unknown) => {
state.outbound.push(message);
return original(message);
}) as typeof worker.postMessage;
broker!.attach(plugin, worker);
},
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result" };
}
it(
"ctx.hosts.get returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
for (const field of [
"password",
"key",
"keyPassword",
"certPublicKey",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"credentialId",
"vaultProfileId",
]) {
expect(Object.keys(result.value)).not.toContain(field);
}
},
WORKER_TIMEOUT,
);
it(
"ctx.hosts.list returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.list();`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.connect returns an opaque handle, not a connection",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return {
handle,
type: typeof handle,
// Anything reachable from the handle, if it were an object.
reachable: typeof handle === "object" ? Object.keys(handle) : null,
};
`);
expect(result.ok).toBe(true);
expect(result.value.type).toBe("string");
expect(result.value.handle).toMatch(/^sshconn:/);
expect(result.value.handle).not.toContain(SENTINEL);
// The handle is random, so it cannot encode the host or its secrets.
expect(result.value.handle).not.toContain("10.0.0.5");
expect(result.value.handle).not.toContain("root");
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.exec returns only stdout, stderr and code",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return await ctx.ssh.exec(handle, "uptime");
`);
expect(result.ok).toBe(true);
expect(Object.keys(result.value).sort()).toEqual([
"code",
"stderr",
"stdout",
]);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"a failing ctx.ssh.connect does not leak the credential in its error",
async () => {
const result = await attack(`return await ctx.ssh.connect(42);`, true);
expect(result.ok).toBe(false);
expect(result.error).not.toContain(SENTINEL);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot walk ctx itself to a credential",
async () => {
const result = await attack(`
const seen = new Set();
const found = [];
(function walk(value, path, depth) {
if (depth > 6 || value === null || seen.has(value)) return;
if (typeof value === "string") {
if (value.includes(${JSON.stringify(SENTINEL)})) found.push(path);
return;
}
if (typeof value !== "object" && typeof value !== "function") return;
seen.add(value);
for (const key of Object.getOwnPropertyNames(value)) {
let inner;
try { inner = value[key]; } catch { continue; }
walk(inner, path + "." + key, depth + 1);
}
})(ctx, "ctx", 0);
return found;
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"no message sent to the worker ever contains credential material",
async () => {
await attack(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const handle = await ctx.ssh.connect(42);
await ctx.ssh.exec(handle, "uptime");
await ctx.storage.set("cache", { host, list });
return await ctx.storage.get("cache");
`);
expect(state.outbound.length).toBeGreaterThan(0);
const leaking = state.outbound.filter((message) =>
deepContains(message, SENTINEL),
);
expect(leaking).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot exec on a handle it did not open",
async () => {
const result = await attack(`
return await ctx.ssh.exec("sshconn:00000000-0000-0000-0000-000000000000", "id");
`);
expect(result.ok).toBe(false);
expect(result.error).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"a closed handle stops working",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
await ctx.ssh.close(handle);
try {
await ctx.ssh.exec(handle, "id");
return "still worked";
} catch (error) {
return error.message;
}
`);
expect(result.value).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"ssh.connect is denied without the granted capability",
async () => {
state.grants = [{ pluginId: "sample-plugin", capability: "hosts.read" }];
invalidatePluginPermissionCache();
const result = await attack(`return await ctx.ssh.connect(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "ssh.exec"');
},
WORKER_TIMEOUT,
);
});
@@ -1,17 +1,18 @@
/**
* The central promise of shipping the terminal as a plugin: disabling it
* releases what it owns, and nothing else notices.
* Disabling a plugin releases what it owns.
*
* 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.
* A plugin that keeps a port after deactivate makes "disabled" a lie and
* makes re-enabling fail on a port that is still held, which is exactly the
* bug the docker console had: its WebSocket server was created at module
* scope, so it survived deactivate and the second activate got nothing.
*
* These use a real listening socket rather than a mock, because the thing
* under test is whether the OS still has the port.
*/
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";
@@ -28,7 +29,7 @@ vi.mock("../../utils/logger.js", () => ({
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginPermissionError: class extends Error {},
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
@@ -45,136 +46,83 @@ function isListening(port: number): Promise<boolean> {
});
}
/** Stands in for the terminal: a first-party plugin that owns a real port. */
function terminalLikeSource(port: number): string {
/**
* A plugin that owns a real port, the way ssh-terminal and docker do.
*
* The server is created inside activate and closed through a disposable, not
* at module scope, which is the rule the runtime documents.
*/
function portOwningSource(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;
}
`;
import net from "node:net";
export async function activate(ctx) {
const server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.disposables.add(
() => new Promise((resolve) => server.close(() => resolve())),
);
}
`;
}
describe("disabling the terminal plugin", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
for (const fixture of fixtures.splice(0)) fixture.cleanup();
clearRegistry();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
});
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),
describe("disabling a plugin releases its resources", () => {
async function start(): Promise<PluginLoader> {
fixture = createFixturePlugin({
backendSource: portOwningSource(TEST_PORT),
});
fixtures.push(terminal);
process.env.TERMIX_BUNDLED_PLUGINS_DIR = fixture.root;
process.env.DATA_DIR = fixture.root;
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");
const instance = new PluginLoader();
await instance.loadAll();
await instance.activate(fixture.id);
loader = instance;
return instance;
}
it("frees the port the plugin was listening on", async () => {
const instance = await start();
expect(await isListening(TEST_PORT)).toBe(true);
await loader.deactivate("ssh-terminal");
await instance.deactivate(fixture!.id);
// 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);
// The regression the docker console hit: the module stays in the ESM cache,
// so the second activate runs against it and has to be able to bind again.
it("can take the port again after being re-enabled", async () => {
const instance = await start();
loader = new PluginLoader();
await loader.load(terminal.dir);
await instance.deactivate(fixture!.id);
expect(await isListening(TEST_PORT)).toBe(false);
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
await instance.activate(fixture!.id);
await loader.deactivate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(false);
expect(await isListening(TEST_PORT)).toBe(true);
expect(instance.get(fixture!.id)?.state).toBe("active");
});
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
it("frees the port on shutdown too", async () => {
const instance = await start();
await instance.shutdown();
loader = null;
expect(await isListening(TEST_PORT)).toBe(false);
});
});
@@ -1,279 +0,0 @@
/**
* 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, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
"docker",
"fleets",
"host-metrics",
"network-topology",
"proxmox",
"remote-desktop",
"ssh-terminal",
"tailscale",
"web-endpoint",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(isFirstParty("fleets")).toBe(true);
expect(isFirstParty("automations")).toBe(true);
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).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,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(true);
expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("web-endpoint", [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);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
expect(runsInProcess("fleets", ["hosts.read"])).toBe(false);
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
expect(runsInProcess("web-endpoint", ["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("allows it for docker", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("docker");
});
it("allows it for host-metrics", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "host-metrics",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("host-metrics");
});
it("allows it for ai", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ai",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ai");
});
it("allows it for proxmox", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "proxmox",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("proxmox");
});
it("allows it for automations", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "automations",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("automations");
});
it("allows it for network-topology", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "network-topology",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("network-topology");
});
it("allows it for tailscale", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "tailscale",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("tailscale");
});
it("allows it for workspaces", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "workspaces",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("workspaces");
});
it("allows it for web-endpoint", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "web-endpoint",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("web-endpoint");
});
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");
});
});
+11 -13
View File
@@ -1,7 +1,7 @@
/**
* Builds a real unpacked plugin on disk for the loader/broker tests.
* Builds a real unpacked plugin on disk for the runtime tests.
*
* The backend entry has to be a genuine .mjs file the worker can import, and
* The backend entry has to be a genuine .mjs file the loader can import, and
* tsc only emits .ts, so these are written at test time rather than committed.
* Same mkdtemp convention the other disk-touching backend tests use.
*/
@@ -13,12 +13,15 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js";
export interface FixtureOptions {
id?: string;
permissions?: string[];
/** Catalog capability ids the manifest declares. */
capabilities?: string[];
/** Body of backend/index.mjs. Must export activate(ctx). */
backendSource?: string;
/** Omit the backend entry file even though the manifest declares one. */
omitBackendEntry?: boolean;
manifestOverrides?: Record<string, unknown>;
/** Write into this root instead of a fresh temp dir. */
root?: string;
}
export interface Fixture {
@@ -30,13 +33,14 @@ export interface Fixture {
const DEFAULT_BACKEND = `
export async function activate(ctx) {
ctx.__ready = true;
ctx.log.info("fixture activated");
}
`;
export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
const id = options.id ?? "sample-plugin";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const root =
options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "backend"), { recursive: true });
@@ -50,14 +54,8 @@ export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux", "win32", "darwin"],
},
permissions: options.permissions ?? ["hosts.read", "storage.own"],
sidecars: [],
capabilities: options.capabilities ?? ["hosts:read", "kv:own"],
backend: "backend/index.mjs",
...options.manifestOverrides,
};
@@ -1,480 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { buildPluginRouter } = await import("../../plugins/http-bridge.js");
const pluginApi = await import("../../database/routes/plugin-api-routes.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
describe("plugin HTTP bridge", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
beforeEach(async () => {
state.grants = [];
const app = express();
app.use("/plugin-api", pluginApi.default);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
async function activate(backendSource: string) {
const fixture = createFixturePlugin({ backendSource });
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
}
it("404s for a plugin that is not running", async () => {
const res = await fetch(`${baseUrl}/plugin-api/ghost/anything`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: "Plugin not installed or not running",
});
});
it(
"dispatches a GET to an active plugin's handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ pong: true });
},
WORKER_TIMEOUT,
);
it(
"passes the body, query and params through to the handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("POST", "/echo/:id", async (req) => ({
id: req.params.id,
q: req.query.q,
body: req.body,
method: req.method,
}));
}
`);
const res = await fetch(
`${baseUrl}/plugin-api/sample-plugin/echo/7?q=hello`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "termix" }),
},
);
expect(await res.json()).toEqual({
id: "7",
q: "hello",
body: { name: "termix" },
method: "POST",
});
},
WORKER_TIMEOUT,
);
it(
"honours an explicit status/body envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/missing", async () => ({
status: 404,
body: { error: "nope" },
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/missing`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "nope" });
},
WORKER_TIMEOUT,
);
it(
"treats a payload with a body field as the body, not an envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/doc", async () => ({
body: "the article text",
title: "A post",
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/doc`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
body: "the article text",
title: "A post",
});
},
WORKER_TIMEOUT,
);
it(
"returns 502 when the plugin handler throws",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/boom", async () => {
throw new Error("handler exploded");
});
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/boom`);
expect(res.status).toBe(502);
expect((await res.json()).detail).toContain("handler exploded");
},
WORKER_TIMEOUT,
);
it(
"does not forward authorization headers or cookies to the plugin",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/headers", async (req) => ({
seen: Object.keys(req.headers).sort(),
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/headers`, {
headers: {
authorization: "Bearer super-secret-token",
cookie: "session=secret",
"content-type": "application/json",
},
});
const seen = (await res.json()).seen as string[];
expect(seen).not.toContain("authorization");
expect(seen).not.toContain("cookie");
expect(seen).toContain("content-type");
},
WORKER_TIMEOUT,
);
it(
"rejects an unsupported method and a path without a leading slash",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const errors = [];
try {
await ctx.http.route("TRACE", "/x", () => null);
} catch (e) { errors.push(e.message); }
try {
await ctx.http.route("GET", "no-slash", () => null);
} catch (e) { errors.push(e.message); }
if (errors.length !== 2) throw new Error("expected two rejections");
await ctx.http.route("GET", "/ok", async () => ({ errors }));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ok`);
const errors = (await res.json()).errors as string[];
expect(errors[0]).toContain("does not support the TRACE method");
expect(errors[1]).toContain('must start with "/"');
},
WORKER_TIMEOUT,
);
it(
"404s again once the plugin is deactivated",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
expect(
(await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`)).status,
).toBe(200);
await loader!.deactivate("sample-plugin");
pluginApi.unregisterPluginRouter("sample-plugin");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(404);
},
WORKER_TIMEOUT,
);
describe("per-request actor identity", () => {
/**
* Exercises the real worker end to end: the fixture's activate() calls
* ctx.hosts.list() from inside a genuine ctx.http.route handler, so the
* AsyncLocalStorage context worker-bootstrap.ts enters around that
* invocation is the thing under test, not a mock of it. Two concurrent
* requests as two different users must each see only their own hosts,
* even though both are in flight on the very same worker at once.
*/
async function activateWithHostsRoute() {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/my-hosts", async () => {
const hosts = await ctx.hosts.list();
return { hosts };
});
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
alice: [{ id: 1, name: "alice-host" }],
bob: [{ id: 2, name: "bob-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
// Activated under "install-owner", a third identity distinct from
// alice and bob, so a test that accidentally fell back to ownerUserId
// instead of the per-request actor would show up as a clear mismatch
// rather than an accidental pass.
await loader.activate("sample-plugin", "install-owner");
}
/**
* Mimics authenticateJWT setting req.userId ahead of the dispatcher, the
* way database.ts really wires it (see plugin-api-routes-auth.test.ts for
* that wiring itself). This file's own app has no auth in front of
* pluginApi.default on purpose, to test the dispatcher in isolation, so
* the per-user identity is injected the same minimal way here.
*/
function fetchAsUser(path: string, userId: string) {
return fetch(`${baseUrl}${path}`, {
headers: { "x-test-user-id": userId },
});
}
it(
"two concurrent requests as different users each see only their own hosts",
async () => {
await activateWithHostsRoute();
// Reopen the server with a stand-in auth middleware that reads the
// test header, since the beforeEach server has none.
await new Promise<void>((resolve) => server?.close(() => resolve()));
const app = express();
app.use((req, _res, next) => {
const userId = req.headers["x-test-user-id"];
if (typeof userId === "string") {
(req as typeof req & { userId?: string }).userId = userId;
}
next();
});
app.use("/plugin-api", pluginApi.default);
server = await new Promise((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const [aliceRes, bobRes] = await Promise.all([
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"),
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"),
]);
expect(await aliceRes.json()).toMatchObject({
hosts: [{ id: 1, name: "alice-host" }],
});
expect(await bobRes.json()).toMatchObject({
hosts: [{ id: 2, name: "bob-host" }],
});
},
WORKER_TIMEOUT,
);
it(
"falls back to the install owner when a call has no inbound request",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const hosts = await ctx.hosts.list();
await ctx.log.info(JSON.stringify({ hosts }));
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
"install-owner": [{ id: 9, name: "owner-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "install-owner");
const line = logged.find((entry) => entry.startsWith("{"));
expect(line && JSON.parse(line)).toMatchObject({
hosts: [{ id: 9, name: "owner-host" }],
});
},
WORKER_TIMEOUT,
);
});
});
@@ -1,200 +0,0 @@
/**
* 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();
});
});
+469 -320
View File
@@ -1,350 +1,499 @@
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";
/**
* The single-tier loader: discovery, dependency order and lifecycle.
*
* These drive the real PluginLoader against real plugin directories written
* to a temp dir, because the things worth testing here are exactly the ones a
* mock would paper over: does a manifest on disk parse, does an id collision
* get refused, does a plugin's own deactivate actually run.
*/
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
}),
}));
// Worker spawn plus crash backoff does not fit the 5s backend default.
const WORKER_TIMEOUT = 30_000;
const cleanups: Array<() => void> = [];
describe("PluginLoader", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function fixture(...args: Parameters<typeof createFixturePlugin>) {
const created = createFixturePlugin(...args);
fixtures.push(created);
return created;
}
/** Points the loader at `bundled` and `user` as its two discovery roots. */
function setRoots(bundled: string, user: string): void {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
}
afterEach(async () => {
await loader?.shutdown();
loader = null;
while (fixtures.length) fixtures.pop()!.cleanup();
beforeEach(() => {
vi.unstubAllEnvs();
});
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
describe("PluginLoader discovery", () => {
it("loads a valid plugin directory", async () => {
const fixture = createFixturePlugin();
cleanups.push(fixture.cleanup);
const loader = new PluginLoader();
const plugin = await loader.load(fixture.dir, "bundled");
expect(plugin.id).toBe("sample-plugin");
expect(plugin.state).toBe("loaded");
expect(plugin.manifest.capabilities).toEqual(["hosts:read", "kv:own"]);
});
describe("load", () => {
it("loads a valid plugin directory", async () => {
const plugin = fixture();
loader = new PluginLoader();
const loaded = await loader.load(plugin.dir);
expect(loaded.id).toBe("sample-plugin");
expect(loaded.state).toBe("loaded");
expect(loaded.worker).toBeNull();
expect(loader.get("sample-plugin")).toBe(loaded);
it("rejects a directory whose name does not match the manifest id", async () => {
const fixture = createFixturePlugin({
manifestOverrides: { id: "something-else" },
});
cleanups.push(fixture.cleanup);
it("rejects a manifest that fails validation", async () => {
const plugin = fixture({ manifestOverrides: { version: "nope" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/Invalid plugin manifest/,
);
});
it("rejects a directory whose name does not match the manifest id", async () => {
const plugin = fixture({ manifestOverrides: { id: "other-id" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/does not match manifest id/,
);
});
it("rejects a declared backend with no entry file", async () => {
const plugin = fixture({ omitBackendEntry: true });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/declares a backend but/,
);
});
it("rejects an unreadable manifest", async () => {
loader = new PluginLoader();
await expect(loader.load("/nonexistent/plugin-dir")).rejects.toThrow(
/Could not read plugin manifest/,
);
});
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/does not match manifest id/,
);
});
describe("activate and deactivate", () => {
it(
"activates a plugin and runs its activate(ctx)",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
it("rejects a manifest whose backend entry is missing", async () => {
const fixture = createFixturePlugin({ omitBackendEntry: true });
cleanups.push(fixture.cleanup);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("active");
expect(loaded.worker).not.toBeNull();
expect(loaded.ownerUserId).toBe("user-1");
},
WORKER_TIMEOUT,
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/backend entry .* is missing/,
);
it(
"surfaces an error thrown inside activate(ctx)",
async () => {
const plugin = fixture({
backendSource: `
export async function activate() {
throw new Error("boom from plugin");
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/boom from plugin/);
},
WORKER_TIMEOUT,
);
it(
"rejects a backend entry with no activate export",
async () => {
const plugin = fixture({
backendSource: `export const nothing = true;`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/does not export an activate\(ctx\) function/);
},
WORKER_TIMEOUT,
);
it(
"deactivate terminates the worker and does not count as a crash",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
await loader.deactivate("sample-plugin");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("stopped");
expect(loaded.worker).toBeNull();
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"does not leak the server's environment into the worker",
async () => {
process.env.TERMIX_TEST_SECRET = "super-secret-value";
const plugin = fixture({
backendSource: `
export async function activate() {
if (process.env.TERMIX_TEST_SECRET) {
throw new Error("leaked:" + process.env.TERMIX_TEST_SECRET);
}
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
expect(loader.get("sample-plugin")!.state).toBe("active");
delete process.env.TERMIX_TEST_SECRET;
},
WORKER_TIMEOUT,
);
it("deactivating a never-started plugin is a no-op", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")!.state).toBe("stopped");
});
it("activating an unknown plugin throws", async () => {
loader = new PluginLoader();
await expect(loader.activate("ghost", "user-1")).rejects.toThrow(
/Plugin ghost is not loaded/,
);
});
});
describe("crash restart", () => {
it(
"restarts a crashed plugin, then disables it after the retry budget",
async () => {
// Exits on a timer so activation succeeds first, then the worker dies.
const plugin = fixture({
backendSource: `
export async function activate() {
setTimeout(() => process.exit(1), 10);
}
`,
});
it("skips an invalid directory and loads the rest", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
loader = new PluginLoader({ restartBackoffMs: [10, 10, 10] });
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
createFixturePlugin({ id: "good-plugin", root: bundled });
const broken = path.join(bundled, "broken-plugin");
fs.mkdirSync(broken, { recursive: true });
fs.writeFileSync(path.join(broken, "manifest.json"), "{ not json");
const loaded = loader.get("sample-plugin")!;
await vi.waitFor(
() => {
expect(loaded.state).toBe("disabled");
},
{ timeout: 25_000, interval: 50 },
);
setRoots(bundled, user);
expect(loaded.restartAttempts).toBeGreaterThan(MAX_RESTART_ATTEMPTS);
expect(loaded.worker).toBeNull();
},
WORKER_TIMEOUT,
);
const loader = new PluginLoader();
const loaded = await loader.loadAll();
it(
"retry clears the crash counter and starts the plugin again",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
// Simulate having been auto-disabled.
await loader.deactivate("sample-plugin");
loaded.state = "disabled";
loaded.restartAttempts = MAX_RESTART_ATTEMPTS + 1;
await loader.retry("sample-plugin");
expect(loaded.state).toBe("active");
// retry() clears the counter up front, before the stability window.
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"clears the crash counter once a plugin stays up past the stability window",
async () => {
const plugin = fixture();
loader = new PluginLoader({ stabilityWindowMs: 50 });
await loader.load(plugin.dir);
const loaded = loader.get("sample-plugin")!;
loaded.restartAttempts = 2;
await loader.activate("sample-plugin", "user-1");
await vi.waitFor(
() => {
expect(loaded.restartAttempts).toBe(0);
},
{ timeout: 5_000, interval: 25 },
);
},
WORKER_TIMEOUT,
);
it("retry on a plugin that never activated throws", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(loader.retry("sample-plugin")).rejects.toThrow(
/never been activated/,
);
});
expect(loaded.map((plugin) => plugin.id)).toEqual(["good-plugin"]);
});
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;
// A plugin dropped into the data directory must never be able to take over
// an id the install already ships, or "ssh-terminal" becomes whatever the
// last person to write that folder wanted it to be.
it("refuses a user plugin that shadows a bundled id", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
const userPlugins = path.join(user, "plugins");
fs.mkdirSync(userPlugins, { recursive: true });
beforeEach(() => {
previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins";
createFixturePlugin({ id: "ssh-terminal", root: bundled });
createFixturePlugin({
id: "ssh-terminal",
root: userPlugins,
manifestOverrides: { name: "Impostor" },
});
afterEach(() => {
if (previousBundled === undefined) {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
} else {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled;
}
});
setRoots(bundled, user);
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";
loader = new PluginLoader();
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(await loader.loadAll()).toEqual([]);
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
});
it("skips invalid directories and loads the rest", async () => {
const good = fixture({ id: "good-plugin" });
// Share the same root so both sit under one plugins dir.
const bad = createFixturePlugin({
id: "bad-plugin",
manifestOverrides: { version: "nope" },
});
fixtures.push(bad);
const fs = await import("node:fs");
const path = await import("node:path");
fs.cpSync(bad.dir, path.join(good.root, "bad-plugin"), {
recursive: true,
});
const previous = process.env.DATA_DIR;
// getPluginsDir() appends "plugins", so point DATA_DIR at the parent.
const dataDir = path.join(good.root, "..");
const pluginsDir = path.join(dataDir, "plugins");
fs.mkdirSync(pluginsDir, { recursive: true });
fs.cpSync(good.dir, path.join(pluginsDir, "good-plugin"), {
recursive: true,
});
fs.cpSync(bad.dir, path.join(pluginsDir, "bad-plugin"), {
recursive: true,
});
process.env.DATA_DIR = dataDir;
loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((p) => p.id)).toEqual(["good-plugin"]);
fs.rmSync(pluginsDir, { recursive: true, force: true });
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
expect(loaded).toHaveLength(1);
expect(loaded[0].source).toBe("bundled");
expect(loaded[0].manifest.name).toBe("Sample Plugin");
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
for (const spec of specs) {
createFixturePlugin({ ...spec, root: bundled });
}
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("orders a dependency before its dependent", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { order, blocked, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(cycles.size).toBe(0);
expect(blocked.size).toBe(0);
expect(order.indexOf("beta")).toBeLessThan(order.indexOf("alpha"));
});
it("blocks a plugin whose hard dependency is not installed", async () => {
const loader = await loadAll([
{
id: "alpha",
manifestOverrides: { dependencies: { missing: "^1.0.0" } },
},
]);
const { order, blocked } = loader.resolveOrder(["alpha"]);
expect(order).toEqual([]);
expect(blocked.get("alpha")).toMatch(/not installed/);
});
it("blocks a plugin whose dependency is present at the wrong version", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^2.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha", "beta"]);
expect(blocked.get("alpha")).toMatch(/1\.0\.0 is installed/);
});
it("blocks a plugin whose dependency is installed but disabled", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha"]);
expect(blocked.get("alpha")).toMatch(/not enabled/);
});
// Nothing to wait for, so this is a failure rather than a block.
it("fails every plugin in a dependency cycle", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta", manifestOverrides: { dependencies: { alpha: "^1.0.0" } } },
]);
const { order, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(order).toEqual([]);
expect(cycles.get("alpha")).toMatch(/cycle/);
expect(cycles.get("beta")).toMatch(/cycle/);
});
it("activates in dependency order and reports blocked plugins", async () => {
const marker = path.join(tempRoot("termix-order-"), "order.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
createFixturePlugin({
id: "gamma",
root: bundled,
manifestOverrides: { dependencies: { nowhere: "^1.0.0" } },
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const result = await loader.activateAll(["alpha", "beta", "gamma"]);
expect(result.activated).toEqual(["beta", "alpha"]);
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"beta",
"alpha",
]);
expect(loader.get("gamma")?.state).toBe("blocked");
expect(loader.get("gamma")?.lastError).toMatch(/not installed/);
});
});
describe("PluginLoader lifecycle", () => {
async function single(
backendSource: string,
id = "sample-plugin",
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({ id, root: bundled, backendSource });
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("runs activate and then deactivate", async () => {
const marker = path.join(tempRoot("termix-life-"), "life.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "up\\n");
}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "down\\n");
}
`);
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("up\ndown\n");
});
it("marks a plugin failed when activate throws", async () => {
const loader = await single(`
export async function activate() {
throw new Error("no good");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("no good");
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("no good");
});
it("rejects a backend entry with no activate export", async () => {
const loader = await single(`export const nothing = true;`);
await expect(loader.activate("sample-plugin")).rejects.toThrow(
/does not export an activate/,
);
});
// The whole point of the bag: cleanup cannot be conditional on the plugin
// behaving well on the way out.
it("disposes everything even when deactivate throws", async () => {
const marker = path.join(tempRoot("termix-dispose-"), "disposed.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "disposed\\n");
});
}
export async function deactivate() {
throw new Error("deactivate blew up");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("disposed\n");
});
// ESM caches the module, so the second activate runs against the same
// module object the first one did. Anything created at module scope would
// already be torn down by then.
it("supports enable, disable, enable without a restart", async () => {
const marker = path.join(tempRoot("termix-cycle-"), "cycle.txt");
const loader = await single(`
import fs from "node:fs";
let timer = null;
let server = null;
export async function activate(ctx) {
server = { closed: false };
timer = setInterval(() => {}, 1000);
ctx.disposables.add(() => {
clearInterval(timer);
server.closed = true;
fs.appendFileSync(${JSON.stringify(marker)}, "closed\\n");
});
fs.appendFileSync(${JSON.stringify(marker)}, "started\\n");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(fs.readFileSync(marker, "utf8")).toBe("started\nclosed\nstarted\n");
await loader.deactivate("sample-plugin");
});
it("disposes registrations made before activate threw", async () => {
const marker = path.join(tempRoot("termix-partial-"), "partial.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "cleaned\\n");
});
throw new Error("halfway");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("halfway");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned\n");
});
it("deactivating a plugin that never started is a no-op", async () => {
const loader = await single(`export async function activate() {}`);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
});
it("shuts plugins down in reverse activation order", async () => {
const marker = path.join(tempRoot("termix-shutdown-"), "shutdown.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
// beta started first, so it stops last: a dependency outlives its
// dependents.
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"alpha",
"beta",
]);
});
});
describe("PluginLoader error budget", () => {
async function failing(threshold: number): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({
errorThreshold: threshold,
errorWindowMs: 60_000,
});
await loader.loadAll();
await loader.activate("sample-plugin");
return loader;
}
it("marks a plugin failed once it trips the threshold", async () => {
const loader = await failing(3);
await loader.reportError("sample-plugin", new Error("one"));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.reportError("sample-plugin", new Error("three"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("three");
});
it("forgets errors that fall outside the window", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({ errorThreshold: 2, errorWindowMs: 10 });
await loader.loadAll();
await loader.activate("sample-plugin");
await loader.reportError("sample-plugin", new Error("one"));
await new Promise((resolve) => setTimeout(resolve, 25));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
});
it("wrap() counts a throwing callback instead of letting it escape", async () => {
const loader = await failing(2);
const wrapped = loader.wrap("sample-plugin", () => {
throw new Error("callback failed");
});
await expect(wrapped()).resolves.toBeUndefined();
await wrapped();
expect(loader.get("sample-plugin")?.state).toBe("failed");
});
it("retry clears the budget and starts the plugin again", async () => {
const loader = await failing(1);
await loader.reportError("sample-plugin", new Error("down"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
await loader.retry("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(loader.get("sample-plugin")?.lastError).toBeNull();
});
});
+394 -438
View File
@@ -1,3 +1,11 @@
/**
* Manifest v2 validation.
*
* The rules live in the SDK so the server, the authoring script and plugin
* authors share one implementation; these tests drive it through the server's
* re-export, which is the path the loader actually takes.
*/
import { describe, expect, it } from "vitest";
import {
parseManifest,
@@ -5,484 +13,432 @@ import {
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function validManifest(overrides: Record<string, unknown> = {}) {
function base(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A sample plugin.",
author: { name: "Jane Doe" },
description: "A fixture.",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: ["hosts.read", "storage.own"],
sidecars: [],
capabilities: ["kv:own"],
...overrides,
};
}
describe("plugin manifest validation", () => {
it("accepts a valid manifest", () => {
expect(validateManifest(validManifest())).toEqual([]);
describe("manifest v2 validation", () => {
it("accepts a minimal valid manifest", () => {
expect(validateManifest(base())).toEqual([]);
});
it("reports every missing required field", () => {
const errors = validateManifest({ id: "x" });
expect(errors).toContain('Missing required field: "description"');
expect(errors).toContain('Missing required field: "engine"');
expect(errors).toContain('Missing required field: "sidecars"');
it("reports every missing required field at once", () => {
const errors = validateManifest({ id: "sample-plugin" });
expect(errors.some((e) => e.includes('"name"'))).toBe(true);
expect(errors.some((e) => e.includes('"version"'))).toBe(true);
expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true);
});
it("rejects a bad id pattern", () => {
const errors = validateManifest(validManifest({ id: "Bad_Id" }));
expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe(
true,
it("rejects an id that is not a lowercase slug", () => {
expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects an id that starts with a digit", () => {
expect(validateManifest(base({ id: "1plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects a non-semver version", () => {
const errors = validateManifest(validManifest({ version: "1.0" }));
expect(errors).toContain(
'Field "version" must be valid semver, got: "1.0"',
expect(validateManifest(base({ version: "1.0" })).join()).toMatch(
/valid semver/,
);
});
describe("provides / requires", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.greet.use"],
};
it("accepts a declared service and requirement", () => {
const errors = validateManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
},
],
requires: [
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
],
contributes: { permissionGroup },
}),
);
expect(errors).toEqual([]);
});
it("rejects a service name that is not dotted", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
});
it("rejects a non-semver service version", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
});
it("rejects a duplicate provided service", () => {
const errors = validateManifest(
validManifest({
provides: [
{ service: "a.b", version: "1.0.0", permission: "x.y" },
{ service: "a.b", version: "2.0.0", permission: "x.y" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("rejects an invalid semver range", () => {
const errors = validateManifest(
validManifest({
requires: [{ service: "a.b", versionRange: "not a range" }],
}),
);
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
});
it("rejects a non-boolean optional", () => {
const errors = validateManifest(
validManifest({
requires: [
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
],
}),
);
expect(errors.some((e) => e.includes("optional"))).toBe(true);
});
it("refuses a service gated by a permission the group never declares", () => {
// Otherwise every call through it would deny against a permission no
// admin could ever grant.
const { manifest, errors } = parseManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.undeclared",
},
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
});
describe("providesSecret / requiresSecret", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.secrets.share"],
};
it("accepts a well-formed pair", () => {
expect(
validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
],
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key", optional: true },
],
contributes: { permissionGroup },
}),
),
).toEqual([]);
});
it("rejects a dotted secret key", () => {
// The owning plugin is already its own field, so a dotted key would only
// encode the owner twice and invite the two disagreeing.
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "ai.api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("providesSecret[0].key"))).toBe(
true,
);
});
it("rejects a duplicate offer of the same key", () => {
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
{ key: "api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("requires a permission on an offer", () => {
const errors = validateManifest(
validManifest({ providesSecret: [{ key: "api-key" }] }),
);
expect(
errors.some((e) => e.includes("providesSecret[0].permission")),
).toBe(true);
});
it("rejects a reference with a bad plugin id", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [{ plugin: "Bad_Id", key: "api-key" }],
}),
);
expect(errors.some((e) => e.includes("requiresSecret[0].plugin"))).toBe(
true,
);
});
it("rejects a duplicate reference to the same plugin and key", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key" },
{ plugin: "ai-assistant", key: "api-key" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("refuses a shared secret gated by a permission the group never declares", () => {
// Same rule as a service: sharing rides on an ordinary role permission,
// so it has to be one an admin can actually grant.
const { manifest, errors } = parseManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.undeclared" },
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
it("refuses a plugin borrowing from itself", () => {
const { manifest, errors } = parseManifest(
validManifest({
requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }],
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("names this plugin itself");
});
});
it("rejects an unknown permission", () => {
const errors = validateManifest(
validManifest({ permissions: ["credentials.read"] }),
);
expect(
errors.some((e) => e.includes('Unknown permission: "credentials.read"')),
).toBe(true);
});
it("rejects an unknown category", () => {
const errors = validateManifest(validManifest({ category: "Games" }));
expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch(
/must be one of/,
);
});
// Capabilities were dotted in v1. They are colon-separated now so they can
// never be confused with the dotted RBAC permissions.
it("rejects a dotted capability name", () => {
expect(
errors.some((e) => e.startsWith('Field "category" must be one of')),
).toBe(true);
validateManifest(base({ capabilities: ["hosts.read"] })).join(),
).toMatch(/not a known capability/);
});
it("rejects a non-object manifest", () => {
expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]);
expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]);
it("rejects a capability outside the catalog", () => {
expect(
validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(),
).toMatch(/not a known capability/);
});
it("validates contributes sub-shapes", () => {
it("rejects a duplicated capability", () => {
expect(
validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(),
).toMatch(/duplicates/);
});
it("refuses a manifest targeting another SDK api version", () => {
const { manifest, errors } = parseManifest(
base({ engine: { termix: ">=2.9.0", api: "2" } }),
);
expect(manifest).toBeUndefined();
expect(errors.join()).toMatch(/SDK API version/);
});
it("fills in the entry point defaults", () => {
const { manifest } = parseManifest(base());
expect(manifest?.backend).toBe("dist/backend.js");
expect(manifest?.frontend).toBe("dist/frontend.js");
expect(manifest?.locales).toBe("locales");
});
});
// v1 accepted unknown fields silently, so a typo'd "contribute" validated
// clean and was then dropped. A manifest has to mean what it says.
describe("unknown field rejection", () => {
it("rejects an unknown top-level field", () => {
expect(validateManifest(base({ sudoEverything: true })).join()).toMatch(
/Unknown field "sudoEverything"/,
);
});
it("rejects a near-miss of a real field name", () => {
expect(validateManifest(base({ contribute: {} })).join()).toMatch(
/Unknown field "contribute"/,
);
});
it("rejects an unknown field inside contributes", () => {
const errors = validateManifest(
validManifest({
base({ contributes: { dashboardCards: [] } }),
);
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
});
it("rejects an unknown field inside a tab", () => {
const errors = validateManifest(
base({
contributes: {
tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }],
apiPrefix: "Bad Prefix",
tabs: [
{
id: "t",
titleKey: "k",
icon: "i",
openFrom: ["rail"],
surprise: 1,
},
],
},
}),
);
expect(
errors.some((e) => e.includes('openFrom has unknown value: "nope"')),
).toBe(true);
expect(
errors.some((e) => e.includes('"contributes.apiPrefix" must match')),
).toBe(true);
expect(errors.join()).toMatch(/Unknown field "surprise"/);
});
describe("actions / actionSlots", () => {
function withActions(contributes: Record<string, unknown>) {
return validateManifest(validManifest({ contributes }));
}
it("rejects an unknown field inside author and engine", () => {
expect(
validateManifest(base({ author: { name: "x", role: "admin" } })).join(),
).toMatch(/Unknown field "role"/);
it("accepts a valid actions and actionSlots pair", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "ai.assistant",
handler: "openWithContext",
icon: "Bot",
slot: "terminal.toolbar",
kind: "button",
},
],
actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }],
});
expect(errors).toEqual([]);
});
it("allows a camelCase segment in an action id", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "openWithContext",
},
],
});
expect(errors).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
const errors = withActions({
actions: [{ id: "notdotted", titleKey: "k", handler: "h" }],
});
expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe(
true,
);
});
it("rejects a duplicate action id", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h" },
{ id: "a.one", titleKey: "k", handler: "h" },
],
});
expect(
errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')),
).toBe(true);
});
it("requires titleKey and a valid handler", () => {
const errors = withActions({
actions: [{ id: "a.one", handler: "not a identifier" }],
});
expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true);
expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true);
});
it("rejects an unknown contribution kind", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" },
],
});
expect(
errors.some((e) => e.includes("actions[0].kind must be one of")),
).toBe(true);
});
it("rejects an unknown value in a slot's accepts", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }],
});
expect(
errors.some((e) =>
e.includes('actionSlots[0].accepts has unknown value: "menu"'),
),
).toBe(true);
});
it("rejects an empty accepts array", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: [] }],
});
expect(
errors.some((e) =>
e.includes("actionSlots[0].accepts must be a non-empty array"),
),
).toBe(true);
});
it("rejects a duplicate slot id", () => {
const errors = withActions({
actionSlots: [
{ id: "terminal.toolbar", accepts: ["button"] },
{ id: "terminal.toolbar", accepts: ["button"] },
],
});
expect(
errors.some((e) => e.includes("actionSlots[1].id is a duplicate")),
).toBe(true);
});
expect(
validateManifest(
base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }),
).join(),
).toMatch(/Unknown field "unsafe"/);
});
describe("parseManifest", () => {
it("refuses an action gated by an undeclared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: { group: "ai", permissions: ["ai.use"] },
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
it("rejects an unknown field inside a provides entry", () => {
const errors = validateManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.use",
elevated: true,
},
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain(
'Action "ai.openWithContext" is gated by "ai.services.use"',
);
});
it("allows an action gated by a declared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: {
group: "ai",
permissions: ["ai.services.use"],
},
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext");
});
},
}),
);
it("allows an ungated action with no permissionGroup at all", () => {
const { errors } = parseManifest(
validManifest({
contributes: {
actions: [{ id: "a.one", titleKey: "k", handler: "h" }],
},
}),
);
expect(errors).toEqual([]);
});
it("narrows a valid manifest", () => {
const { manifest, errors } = parseManifest(validManifest());
expect(errors).toEqual([]);
expect(manifest?.id).toBe("sample-plugin");
});
it("refuses a manifest targeting a different SDK major", () => {
const { manifest, errors } = parseManifest(
validManifest({ engine: { termix: ">=2.9.0", api: "99" } }),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain('targets SDK api version "99"');
});
it("does not narrow when validation failed", () => {
const { manifest, errors } = parseManifest({ id: "x" });
expect(manifest).toBeUndefined();
expect(errors.length).toBeGreaterThan(0);
});
expect(errors.join()).toMatch(/Unknown field "elevated"/);
});
});
describe("dependencies", () => {
it("accepts a well-formed dependency map", () => {
expect(
validateManifest(base({ dependencies: { automations: "^1.0.0" } })),
).toEqual([]);
});
it("rejects a range that is not valid semver", () => {
expect(
validateManifest(
base({ dependencies: { automations: "latest!" } }),
).join(),
).toMatch(/valid semver range/);
});
it("refuses a plugin depending on itself", () => {
const { errors } = parseManifest(
base({ dependencies: { "sample-plugin": "^1.0.0" } }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses the same plugin in both dependency maps", () => {
const { errors } = parseManifest(
base({
dependencies: { automations: "^1.0.0" },
optionalDependencies: { automations: "^1.0.0" },
}),
);
expect(errors.join()).toMatch(/both dependencies and optionalDependencies/);
});
});
describe("permission groups", () => {
it("accepts a group and its declared permissions", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
),
).toEqual([]);
});
// Without this a manifest could hand any role admin.* at boot.
it("refuses a role default outside the plugin's own permissions", () => {
const errors = validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { user: ["admin.users.manage"] },
},
},
}),
);
expect(errors.join()).toMatch(/does not declare/);
});
it("accepts a role default the plugin does declare", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { admin: ["sample-plugin.use"] },
},
},
}),
),
).toEqual([]);
});
});
describe("services, secrets and actions", () => {
it("refuses a service gated by a permission the group never declares", () => {
const { errors } = parseManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.missing",
},
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a shared secret gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
providesSecret: [
{ key: "api-key", permission: "sample-plugin.missing" },
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a plugin borrowing a secret from itself", () => {
const { errors } = parseManifest(
base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses an action gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
actions: [
{
id: "sample.open",
titleKey: "k",
handler: "open",
permission: "sample-plugin.missing",
},
],
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("allows a camelCase segment in an action id", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "ai.openWithContext", titleKey: "k", handler: "open" },
],
},
}),
),
).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
expect(
validateManifest(
base({
contributes: {
actions: [{ id: "open", titleKey: "k", handler: "open" }],
},
}),
).join(),
).toMatch(/dotted action id/);
});
it("rejects a handler that is not an identifier", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "sample.open", titleKey: "k", handler: "not valid!" },
],
},
}),
).join(),
).toMatch(/JavaScript identifier/);
});
});
describe("tabs and host capabilities", () => {
it("rejects an unknown openFrom value", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] },
],
},
}),
).join(),
).toMatch(/openFrom\[0\] must be one of/);
});
it("rejects duplicate tab ids", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] },
{ id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] },
],
},
}),
).join(),
).toMatch(/duplicates/);
});
it("accepts hostCapability as a single object or an array", () => {
const one = {
key: "enableThing",
labelKey: "k",
editorTab: "general",
};
expect(
validateManifest(base({ contributes: { hostCapability: one } })),
).toEqual([]);
expect(
validateManifest(
base({
contributes: {
hostCapability: [one, { ...one, key: "enableOther" }],
},
}),
),
).toEqual([]);
});
it("rejects duplicate hostCapability keys", () => {
const one = { key: "enableThing", labelKey: "k", editorTab: "general" };
expect(
validateManifest(
base({ contributes: { hostCapability: [one, one] } }),
).join(),
).toMatch(/duplicates/);
});
});
@@ -2,11 +2,9 @@
* Two real plugins on disk, one offering a secret and one borrowing it, driven
* through the actual PluginLoader.
*
* Same borrowed-id trick as service-contract.test.ts: the in-process allowlist
* is hardcoded and deliberately unfakeable, so the fixtures use real
* first-party ids to reach that tier. The secret ("api-key") and the
* permission gating it ("testplugin.secrets.share") are fixture-owned, so
* nothing about the shipped ssh-terminal or docker plugins is under test.
* The secret ("api-key") and the permission gating it
* ("testplugin.secrets.share") are fixture-owned, so nothing about the
* shipped plugins is under test even though the fixtures borrow their ids.
*
* What the unit test cannot show, and this does: that the manifest declaration
* is enforced by the real ctx, and that a genuine uninstall through
@@ -53,8 +51,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearSecretRegistry,
getSecretRegistration,
@@ -107,7 +103,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
providesSecret: [{ key: KEY, permission: PERMISSION }],
@@ -125,7 +121,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: CONSUMER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requiresSecret },
});
@@ -286,7 +282,7 @@ describe("cross-plugin secret references", () => {
it("refuses to offer a secret the provider's manifest never declared", async () => {
provider = createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.secrets.offer("undeclared-key", () => "nope");
@@ -2,12 +2,9 @@
* Two real plugins on disk, one providing a service and one requiring it,
* driven through the actual PluginLoader.
*
* The ids are the real first-party ones because the in-process allowlist is
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
* does. The service ("testplugin.greet") and the permission gating it
* The service ("testplugin.greet") and the permission gating it
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
* ssh-terminal or docker plugins is under test here; only their ids are
* borrowed to reach the in-process tier.
* plugins is under test here even though the fixtures borrow their ids.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -51,8 +48,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearServiceRegistry,
getRegistration,
@@ -99,7 +94,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
provides: [
@@ -119,7 +114,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
@@ -212,7 +207,7 @@ describe("plugin service contracts", () => {
await expect(loader.activate(plugin.id)).rejects.toThrow(
/no active plugin provides|provides 1\.2\.0/,
);
expect(plugin.state).toBe("crashed");
expect(plugin.state).toBe("failed");
});
it("activates anyway when an unsatisfied requirement is optional", async () => {
@@ -323,7 +318,7 @@ describe("plugin service contracts", () => {
it("refuses to provide a service the manifest never declared", async () => {
provider = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
+146
View File
@@ -0,0 +1,146 @@
/**
* Core owns shutdown.
*
* gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every
* plugin's deactivate and disposes what it registered. A plugin does not need
* its own process signal handler, and must not have one: the docker console
* used to install a SIGTERM listener that called process.exit(0), which
* skipped the rest of the sequence including other plugins and the database
* flush.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } 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),
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
const CONSOLE_PORT = 39109;
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
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));
});
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
/**
* Shaped like the docker plugin: a REST server and a console WebSocket
* server, both started inside activate and closed through disposables.
*/
const DOCKER_LIKE = `
import net from "node:net";
export async function activate(ctx) {
const console = net.createServer();
await new Promise((resolve) =>
console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve),
);
ctx.disposables.add(
() => new Promise((resolve) => console.close(() => resolve())),
);
}
`;
describe("shutdownPlugins closes what plugins own", () => {
it("closes a console-style WebSocket server on shutdown", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "docker",
root: bundled,
backendSource: DOCKER_LIKE,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activate("docker");
expect(await isListening(CONSOLE_PORT)).toBe(true);
await loader.shutdown();
expect(await isListening(CONSOLE_PORT)).toBe(false);
});
// One plugin failing to shut down cleanly must not strand the others,
// which is the other half of why core drives this rather than each plugin.
it("keeps going when one plugin's deactivate throws", async () => {
const marker = path.join(tempRoot("termix-marker-"), "stopped.txt");
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: `
export async function activate() {}
export async function deactivate() {
throw new Error("alpha refuses to stop");
}
`,
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n");
}
`,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
expect(loader.get("alpha")?.state).toBe("stopped");
expect(loader.get("beta")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n");
});
});
@@ -1,121 +0,0 @@
/**
* 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);
});
});