mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user