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

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

No files matched your search

+71 -1
View File
@@ -689,6 +689,7 @@ async function initializeCompleteDatabase(): Promise<void> {
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
@@ -701,7 +702,9 @@ async function initializeCompleteDatabase(): Promise<void> {
plugin_id TEXT NOT NULL,
capability TEXT NOT NULL,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by TEXT NOT NULL,
-- Nullable: a bundled grant is made by the install, not by a user.
granted_by TEXT,
source TEXT NOT NULL DEFAULT 'admin',
UNIQUE (plugin_id, capability),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
@@ -987,6 +990,63 @@ const addColumnIfNotExists = (
}
};
/**
* Drops the NOT NULL on plugin_permission_grants.granted_by.
*
* A bundled grant is made by the install rather than by a person, so there is
* no user id to record. SQLite cannot relax a constraint in place, so the
* table is rebuilt; it only ever holds a handful of rows.
*/
const relaxPluginGrantGrantedBy = () => {
try {
const sql = sqlite
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'plugin_permission_grants'`,
)
.get() as { sql?: string } | undefined;
if (!sql?.sql || !/granted_by\s+TEXT\s+NOT\s+NULL/i.test(sql.sql)) {
return;
}
sqlite.exec(`
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE plugin_permission_grants_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
capability TEXT NOT NULL,
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by TEXT,
source TEXT NOT NULL DEFAULT 'admin',
UNIQUE (plugin_id, capability),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
INSERT INTO plugin_permission_grants_new
(id, plugin_id, capability, granted_at, granted_by, source)
SELECT id, plugin_id, capability, granted_at, granted_by, 'admin'
FROM plugin_permission_grants;
DROP TABLE plugin_permission_grants;
ALTER TABLE plugin_permission_grants_new RENAME TO plugin_permission_grants;
COMMIT;
PRAGMA foreign_keys=ON;
`);
databaseLogger.info(
"Relaxed plugin_permission_grants.granted_by to allow bundled grants",
{ operation: "schema_migration" },
);
} catch (error) {
databaseLogger.warn(
`Failed to relax plugin_permission_grants.granted_by: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "schema_migration" },
);
}
};
const migrateSchema = () => {
addColumnIfNotExists(
"session_recordings",
@@ -2836,6 +2896,16 @@ const migrateSchema = () => {
}
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
"TEXT NOT NULL DEFAULT 'admin'",
);
relaxPluginGrantGrantedBy();
const syncIdTables = [
"ssh_data",
"ssh_credentials",
+13 -3
View File
@@ -2142,7 +2142,10 @@ export const plugins = mysqlTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: varchar("registry_id", { length: 255 }),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -2168,9 +2171,16 @@ export const pluginPermissionGrants = mysqlTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
+13 -3
View File
@@ -2143,7 +2143,10 @@ export const plugins = pgTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: varchar("registry_id", { length: 255 }),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -2169,9 +2172,16 @@ export const pluginPermissionGrants = pgTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
+13 -3
View File
@@ -2141,7 +2141,10 @@ export const plugins = sqliteTable(
tier: text("tier").notNull().default("available"),
source: text("source").notNull().default("community"),
registryId: text("registry_id"),
/** enabled | disabled | blocked | failed */
state: text("state").notNull().default("disabled"),
/** Why the plugin is blocked or failed, for the admin UI. */
lastError: text("last_error"),
installedAt: text("installed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -2167,9 +2170,16 @@ export const pluginPermissionGrants = sqliteTable(
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/**
* Who granted it. Null for a bundled grant, which no user made: shipping
* in the install is the consent. Nullable also stops a user deletion from
* cascading a bundled plugin's capabilities away.
*/
grantedBy: text("granted_by").references(() => users.id, {
onDelete: "cascade",
}),
/** admin | bundled */
source: text("source").notNull().default("admin"),
},
// A plugin's grants are always read together, and re-granting the same
// capability should update the existing row rather than duplicate it.
@@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord =
export interface PluginPermissionGrantCreateInput {
pluginId: string;
capability: string;
grantedBy: string;
/** Null for a bundled grant, which no user made. */
grantedBy: string | null;
source?: "admin" | "bundled";
}
export class PluginPermissionGrantRepository {
@@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository {
pluginId: input.pluginId,
capability: input.capability,
grantedBy: input.grantedBy,
source: input.source ?? "admin",
grantedAt: now,
},
);
@@ -18,6 +18,7 @@ export interface PluginCreateInput {
source?: string;
registryId?: string | null;
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
manifestJson: string;
}
@@ -29,6 +30,7 @@ export interface PluginUpdateInput {
source?: string;
registryId?: string | null;
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
manifestJson?: string;
}
@@ -64,6 +66,7 @@ export class PluginRepository {
source: input.source ?? "community",
registryId: input.registryId ?? null,
state: input.state ?? "disabled",
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
manifestJson: input.manifestJson,
installedAt: now,
@@ -95,6 +98,8 @@ export class PluginRepository {
? existing.registryId
: input.registryId,
state: input.state ?? existing.state,
lastError:
input.lastError === undefined ? existing.lastError : input.lastError,
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
+154 -44
View File
@@ -31,61 +31,88 @@ const requireManagePlugins = permissionManager.requirePermission(
* summary: List installed plugins and their runtime state
* description: >
* Returns every plugin known to the database, merged with the loader's
* live state so the UI can tell "enabled but crashed" from "disabled".
* live state so the UI can tell "enabled but failed" from "disabled".
* Open to any authenticated user, not just admins: the app shell calls
* this on every session to decide which plugin-contributed tabs and
* rail items to register, so gating it behind admin.plugins.manage
* would break the shell for non-admin users. Only the mutating routes
* below (enable/disable, grant/revoke) require that permission.
* would break the shell for non-admin users.
*
* Non-admins get only what the shell needs. What a plugin is allowed to
* do, what has been granted to it and why it failed are operational
* details, so capabilities, grants and lastError are included only for
* holders of admin.plugins.manage.
* tags:
* - Plugins
* responses:
* 200:
* description: List of plugins.
*/
router.get("/", authenticateJWT, async (_req: Request, res: Response) => {
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId as string;
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const live = new Map(loader.list().map((p) => [p.id, p]));
const grantRepository = createCurrentPluginPermissionGrantRepository();
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let permissions: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
permissions?: unknown;
};
contributes = manifest?.contributes ?? null;
permissions = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
: [];
} catch {
contributes = null;
}
const grants = await grantRepository.listByPlugin(record.id);
return {
id: record.id,
name: record.name,
version: record.version,
tier: record.tier,
source: record.source,
enabled: record.state === "enabled",
runtimeState: loaded?.state ?? "stopped",
lastError: loaded?.lastError ?? null,
contributes,
permissions,
grantedCapabilities: grants.map((grant) => grant.capability),
};
}),
const canManage = await permissionManager.hasPermission(
userId,
"admin.plugins.manage",
);
// One query for every plugin rather than one per plugin.
const grantsByPlugin = new Map<string, string[]>();
if (canManage) {
const grantRepository = createCurrentPluginPermissionGrantRepository();
await Promise.all(
records.map(async (record) => {
const grants = await grantRepository.listByPlugin(record.id);
grantsByPlugin.set(
record.id,
grants.map((grant) => grant.capability),
);
}),
);
}
const plugins = records.map((record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let capabilities: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
};
if (!canManage) return summary;
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
});
res.json(plugins);
} catch (error) {
databaseLogger.error(
@@ -135,7 +162,6 @@ router.patch(
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const pluginId = String(req.params.id);
const { enabled } = req.body ?? {};
@@ -157,12 +183,13 @@ router.patch(
// acts on it rather than silently reverting.
await repository.update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
const { activatePlugin, deactivatePlugin } =
await import("../../plugins/index.js");
if (enabled) await activatePlugin(pluginId, userId);
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
databaseLogger.info(
@@ -182,6 +209,89 @@ router.patch(
},
);
/**
* @openapi
* /plugins/{id}/retry:
* post:
* summary: Start a plugin again after it failed
* description: >
* Clears the plugin's error budget and activates it. Use after fixing
* whatever made it fail, such as installing a missing dependency or
* freeing a port it needs.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The plugin's state after the attempt.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 500:
* description: The plugin failed to start again.
*/
router.post(
"/:id/retry",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
if (!record) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const { loader } = getPluginRuntime();
if (!loader.get(pluginId)) {
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
await repository.update(pluginId, {
state: "enabled",
lastError: null,
});
await loader.retry(pluginId);
databaseLogger.info(`Plugin ${pluginId} restarted`, {
operation: "plugin_retry",
pluginId,
});
res.json({ id: pluginId, state: loader.get(pluginId)?.state });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
databaseLogger.error(
`Failed to retry plugin ${pluginId}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_retry" },
);
try {
await createCurrentPluginRepository().update(pluginId, {
state: "failed",
lastError: message,
});
} catch {
// Reporting the failure must not mask it.
}
res.status(500).json({ error: "Failed to start the plugin" });
}
},
);
/**
* @openapi
* /plugins/{id}/grants:
@@ -244,10 +354,10 @@ router.post(
let declared: string[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
permissions?: unknown;
capabilities?: unknown;
};
declared = Array.isArray(manifest?.permissions)
? (manifest.permissions as string[])
declared = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
} catch {
declared = [];
+63
View File
@@ -0,0 +1,63 @@
/**
* Who a plugin is acting as right now.
*
* Two ways a plugin gets an actor, and no third:
*
* - A request. A4's HTTP and WebSocket middleware runs the handler inside
* runAsActor with the user core already authenticated, so a route handler
* acts as the person who called it.
* - ctx.asUser(userId, fn), for background work with no request behind it.
* That call is always audited.
*
* Nothing else. A plugin cannot pass a user id to a guarded ctx method and
* have it believed, which is what stops the forged-caller class of bug the
* worker broker had.
*
* AsyncLocalStorage rather than a module variable because plugin work
* interleaves: two requests in flight would otherwise overwrite each other's
* identity between awaits.
*/
import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser";
}
const storage = new AsyncLocalStorage<ActorStore>();
export function runAsActor<T>(
userId: string,
source: ActorStore["source"],
fn: () => T,
): T {
return storage.run({ userId, source }, fn);
}
export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
export function getActorSource(): ActorStore["source"] | undefined {
return storage.getStore()?.source;
}
export class PluginActorError extends Error {
readonly code = "EPLUGINACTOR";
constructor(pluginId: string, operation: string) {
super(
`Plugin "${pluginId}" called ${operation} with no acting user. ` +
`Wrap background work in ctx.asUser(userId, fn).`,
);
this.name = "PluginActorError";
}
}
export function requireActor(pluginId: string, operation: string): string {
const userId = getActor();
if (!userId) throw new PluginActorError(pluginId, operation);
return userId;
}
-787
View File
@@ -1,787 +0,0 @@
/**
* The main-thread half of the plugin boundary.
*
* Every ctx call a plugin makes arrives here as a message. For each one the
* broker, in this order:
* 1. looks up the method
* 2. checks the plugin's granted capability (manifest-declared AND granted)
* 3. performs the real action
* 4. writes one audit line attributed to the plugin
* 5. replies with a structured-cloneable value
*
* Steps 2 and 4 are not the plugin's to influence. The plugin supplies only the
* arguments and the audit `details`; the actor, action and outcome are set from
* values the broker controls, so a plugin cannot forge an audit trail or
* attribute its actions to a user.
*/
import type { Worker } from "node:worker_threads";
import { randomUUID } from "node:crypto";
import { pluginLogger } from "../utils/logger.js";
import { getAuditUsername, logAudit } from "../utils/audit-logger.js";
import type { LoadedPlugin } from "./loader.js";
import { assertCapability, PluginPermissionError } from "./permissions.js";
import { toPluginHostView, type PluginHostView } from "./host-view.js";
import { pluginEvents } from "./events.js";
import {
isPluginRequest,
isPluginResponse,
type PluginPush,
type PluginRequest,
type PluginResponse,
} from "./protocol.js";
export interface PluginStorageBackend {
get: (pluginId: string, key: string) => Promise<string | null>;
set: (pluginId: string, key: string, value: string) => Promise<void>;
delete: (pluginId: string, key: string) => Promise<boolean>;
listKeys: (pluginId: string) => Promise<string[]>;
}
export interface BrokerDeps {
/**
* Injected rather than imported so tests can exercise the gate without a
* database, and so the heavy hosts modules stay lazily reachable.
*/
listHosts: (userId: string) => Promise<Record<string, unknown>[]>;
resolveHost: (
hostId: number,
userId: string,
) => Promise<Record<string, unknown> | null>;
storage?: PluginStorageBackend;
/** Fired when a plugin's route table changes, so it can be re-mounted. */
onRoutesChanged?: (runtime: PluginRuntime) => void;
ssh?: PluginSshBackend;
}
/**
* The SSH operations the broker performs on a plugin's behalf.
*
* Note what is NOT here: there is no "get me the host" and no "give me the
* client". A plugin's reachable surface is open-a-session and run-a-command,
* both of which take an opaque handle. The resolved host, which carries
* plaintext credentials, only ever exists inside these functions.
*/
export interface PluginSshBackend {
open: (
hostId: number,
userId: string,
) => Promise<{ release: () => void; exec: SshExec }>;
}
export type SshExec = (
command: string,
timeoutMs: number,
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
/** ctx.storage caps, so one plugin cannot fill the database. */
const MAX_STORAGE_KEY_LENGTH = 128;
const MAX_STORAGE_VALUE_BYTES = 256 * 1024;
const MIN_SCHEDULE_INTERVAL_MS = 1000;
const HTTP_HANDLER_TIMEOUT_MS = 30_000;
/** Short: a disable should feel immediate even when a plugin is unresponsive. */
const DEACTIVATE_TIMEOUT_MS = 5_000;
const MAX_SSH_HANDLES = 8;
const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000;
const MAX_SSH_EXEC_TIMEOUT_MS = 120_000;
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
interface PendingHttpRoute {
method: string;
path: string;
}
export interface PluginRuntime {
plugin: LoadedPlugin;
worker: Worker;
/** Routes the plugin registered via ctx.http.route. */
routes: PendingHttpRoute[];
/** Topics the plugin subscribed to via ctx.events.on. */
subscriptions: Set<string>;
/** Bus unsubscribe functions, run on detach so no listener outlives it. */
eventUnsubscribers: Array<() => void>;
/** ctx.schedule timers, cleared on deactivate. */
timers: Map<string, NodeJS.Timeout>;
/** Live SSH handles, keyed by the opaque id the worker holds. */
sshHandles: Map<
string,
{ hostId: number; release: () => void; exec?: SshExec }
>;
disposed: boolean;
}
/** Methods that need a granted capability before the broker will run them. */
const REQUIRED_CAPABILITY: Record<string, string> = {
"hosts.list": "hosts.read",
"hosts.get": "hosts.read",
"ssh.connect": "ssh.exec",
"ssh.exec": "ssh.exec",
"storage.get": "storage.own",
"storage.set": "storage.own",
"storage.delete": "storage.own",
"storage.list": "storage.own",
"events.subscribe": "events.read",
};
/** Methods whose invocation is worth an audit line. */
const AUDITED = new Set(Object.keys(REQUIRED_CAPABILITY));
export class PluginBroker {
private readonly runtimes = new Map<string, PluginRuntime>();
/** Outstanding route invocations, keyed by the push id sent to the worker. */
private readonly httpWaiters = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
/**
* Push ids start high so they cannot collide with the worker's own request
* ids, which start at 1 and count up independently.
*/
private nextPushId = 1_000_000;
constructor(private readonly deps: BrokerDeps) {}
get(pluginId: string): PluginRuntime | undefined {
return this.runtimes.get(pluginId);
}
/** Wired to PluginLoader's onWorkerReady hook. */
attach(plugin: LoadedPlugin, worker: Worker): PluginRuntime {
this.detach(plugin.id);
const runtime: PluginRuntime = {
plugin,
worker,
routes: [],
subscriptions: new Set(),
eventUnsubscribers: [],
timers: new Map(),
sshHandles: new Map(),
disposed: false,
};
this.runtimes.set(plugin.id, runtime);
worker.on("message", (message: unknown) => {
if (isPluginRequest(message)) {
void this.dispatch(runtime, message);
return;
}
// A route handler's reply comes back shaped like a response.
if (isPluginResponse(message)) this.settleHttpReply(message);
});
return runtime;
}
/** Wired to PluginLoader's onWorkerGone hook. Releases everything. */
detach(pluginId: string): void {
const runtime = this.runtimes.get(pluginId);
if (!runtime) return;
runtime.disposed = true;
for (const timer of runtime.timers.values()) clearInterval(timer);
runtime.timers.clear();
for (const handle of runtime.sshHandles.values()) {
try {
handle.release();
} catch {
// Releasing a dead connection is not worth failing over.
}
}
runtime.sshHandles.clear();
for (const unsubscribe of runtime.eventUnsubscribers) {
try {
unsubscribe();
} catch {
// Already detached.
}
}
runtime.eventUnsubscribers = [];
runtime.subscriptions.clear();
this.runtimes.delete(pluginId);
}
private settleHttpReply(message: PluginResponse): void {
const waiter = this.httpWaiters.get(message.id);
if (!waiter) return;
this.httpWaiters.delete(message.id);
if (message.ok === true) waiter.resolve(message.value);
else waiter.reject(new Error(message.error.message));
}
private async dispatch(
runtime: PluginRuntime,
request: PluginRequest,
): Promise<void> {
const { method, args, callerUserId } = request;
let failure: Error | null = null;
try {
const required = REQUIRED_CAPABILITY[method];
if (required) {
await assertCapability(
runtime.plugin.id,
required,
runtime.plugin.manifest.permissions,
);
}
const value = await this.invoke(runtime, method, args, callerUserId);
this.reply(runtime, { id: request.id, ok: true, value });
} catch (error) {
failure = error instanceof Error ? error : new Error(String(error));
this.reply(runtime, {
id: request.id,
ok: false,
error: {
// Sanitised, never the raw message: errors thrown deep in the SSH
// stack can embed the connect config, credentials included.
message: safeErrorMessage(method, failure),
code: (failure as Error & { code?: string }).code,
},
});
}
if (AUDITED.has(method)) {
void this.audit(runtime, method, args, failure, callerUserId);
}
}
private reply(runtime: PluginRuntime, message: unknown): void {
if (runtime.disposed) return;
try {
runtime.worker.postMessage(message);
} catch {
// Worker already gone; the reply has nowhere to land.
}
}
private async invoke(
runtime: PluginRuntime,
method: string,
args: unknown[],
callerUserId?: string,
): Promise<unknown> {
switch (method) {
case "log.debug":
case "log.info":
case "log.warn":
case "log.error":
return this.handleLog(runtime, method, args);
case "audit":
// The audit line is written by the caller path below; nothing to do
// here beyond accepting the call.
return null;
case "hosts.list":
return this.handleHostsList(runtime, callerUserId);
case "hosts.get":
return this.handleHostsGet(runtime, Number(args[0]), callerUserId);
case "storage.get":
return this.handleStorageGet(runtime, storageKey(args[0]));
case "storage.set":
return this.handleStorageSet(runtime, args);
case "storage.delete":
return this.storage().delete(runtime.plugin.id, storageKey(args[0]));
case "storage.list":
return this.storage().listKeys(runtime.plugin.id);
case "schedule.every":
return this.handleScheduleEvery(runtime, Number(args[0]));
case "schedule.cancel":
return this.handleScheduleCancel(runtime, String(args[0]));
case "http.route":
return this.handleHttpRoute(runtime, String(args[0]), String(args[1]));
case "ssh.connect":
return this.handleSshConnect(runtime, Number(args[0]), callerUserId);
case "ssh.exec":
return this.handleSshExec(
runtime,
String(args[0]),
String(args[1]),
args[2] === undefined ? undefined : Number(args[2]),
);
case "ssh.close":
return this.handleSshClose(runtime, String(args[0]));
case "events.emit":
return this.handleEventsEmit(runtime, String(args[0]), args[1]);
case "events.subscribe":
return this.handleEventsSubscribe(runtime, String(args[0]));
default:
throw new PluginFacingError(`Unknown ctx method "${method}"`);
}
}
private handleLog(
runtime: PluginRuntime,
method: string,
args: unknown[],
): null {
const level = method.slice("log.".length) as
"debug" | "info" | "warn" | "error";
const message = String(args[0] ?? "");
// Forced context: a plugin cannot pretend to be another plugin in the log.
const context = { operation: `plugin:${runtime.plugin.id}` };
if (level === "error") {
pluginLogger.error(message, undefined, context);
} else {
pluginLogger[level](message, context);
}
return null;
}
private async handleHostsList(
runtime: PluginRuntime,
callerUserId?: string,
): Promise<PluginHostView[]> {
const userId = this.requireActor(runtime, callerUserId);
const hosts = await this.deps.listHosts(userId);
return hosts.map(toPluginHostView);
}
private async handleHostsGet(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<PluginHostView | null> {
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("hosts.get requires a numeric host id");
}
const host = await this.deps.resolveHost(hostId, userId);
// resolveHostById returns null for both "missing" and "no access", and the
// plugin gets the same answer for both so it cannot probe for existence.
return host ? toPluginHostView(host) : null;
}
private storage(): PluginStorageBackend {
if (!this.deps.storage) {
throw new PluginFacingError(
"ctx.storage is not available in this runtime",
);
}
return this.deps.storage;
}
private async handleStorageGet(
runtime: PluginRuntime,
key: string,
): Promise<unknown> {
const raw = await this.storage().get(runtime.plugin.id, key);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
// A row written by an older shape should not break the plugin.
return null;
}
}
private async handleStorageSet(
runtime: PluginRuntime,
args: unknown[],
): Promise<null> {
const key = storageKey(args[0]);
// Serialised here rather than in the worker so the stored shape is the
// broker's decision, not something a plugin can control.
const value = JSON.stringify(args[1] ?? null);
if (Buffer.byteLength(value, "utf8") > MAX_STORAGE_VALUE_BYTES) {
throw new PluginFacingError(
`ctx.storage value for "${key}" exceeds the ${MAX_STORAGE_VALUE_BYTES} byte limit`,
);
}
await this.storage().set(runtime.plugin.id, key, value);
return null;
}
private handleScheduleEvery(
runtime: PluginRuntime,
intervalMs: number,
): string {
if (!Number.isFinite(intervalMs) || intervalMs < MIN_SCHEDULE_INTERVAL_MS) {
throw new PluginFacingError(
`ctx.schedule.every requires an interval of at least ${MIN_SCHEDULE_INTERVAL_MS}ms`,
);
}
const id = `timer:${randomUUID()}`;
const timer = setInterval(() => {
if (runtime.disposed) return;
this.push(runtime, { kind: "timer", key: id, payload: null });
}, intervalMs);
// A plugin timer must not keep the server alive on its own.
timer.unref?.();
runtime.timers.set(id, timer);
return id;
}
private handleScheduleCancel(runtime: PluginRuntime, id: string): null {
const timer = runtime.timers.get(id);
if (timer) {
clearInterval(timer);
runtime.timers.delete(id);
}
return null;
}
/**
* Opens a pooled connection and returns an opaque handle.
*
* The handle is a random id, not anything derived from the host or its
* credentials, and it is only meaningful inside this runtime's own map. The
* ssh2 Client and the resolved host never enter a postMessage payload.
*/
private async handleSshConnect(
runtime: PluginRuntime,
hostId: number,
callerUserId?: string,
): Promise<string> {
const userId = this.requireActor(runtime, callerUserId);
if (!Number.isFinite(hostId)) {
throw new PluginFacingError("ssh.connect requires a numeric host id");
}
if (!this.deps.ssh) {
throw new PluginFacingError("ctx.ssh is not available in this runtime");
}
if (runtime.sshHandles.size >= MAX_SSH_HANDLES) {
throw new PluginFacingError(
`ctx.ssh is limited to ${MAX_SSH_HANDLES} concurrent connections per plugin`,
);
}
const session = await this.deps.ssh.open(hostId, userId);
const id = `sshconn:${randomUUID()}`;
runtime.sshHandles.set(id, {
hostId,
release: session.release,
exec: session.exec,
});
return id;
}
private async handleSshExec(
runtime: PluginRuntime,
handleId: string,
command: string,
timeoutMs?: number,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
const handle = runtime.sshHandles.get(handleId);
// An unknown handle is indistinguishable from another plugin's handle:
// the map is per-runtime, so ids cannot be guessed across plugins.
if (!handle?.exec) {
throw new PluginFacingError("Unknown or closed SSH connection handle");
}
if (!command) throw new PluginFacingError("ssh.exec requires a command");
const timeout =
timeoutMs && Number.isFinite(timeoutMs)
? Math.min(timeoutMs, MAX_SSH_EXEC_TIMEOUT_MS)
: DEFAULT_SSH_EXEC_TIMEOUT_MS;
return handle.exec(command, timeout);
}
private handleSshClose(runtime: PluginRuntime, handleId: string): null {
const handle = runtime.sshHandles.get(handleId);
if (!handle) return null;
runtime.sshHandles.delete(handleId);
try {
handle.release();
} catch {
// Releasing a dead connection is not worth failing over.
}
return null;
}
/**
* A plugin may only emit under its own namespace. Without this a plugin
* could publish "host.status" and drive the automations engine as though the
* metrics poller had reported it.
*/
private handleEventsEmit(
runtime: PluginRuntime,
topic: string,
payload: unknown,
): null {
const prefix = `plugin.${runtime.plugin.id}.`;
if (!topic.startsWith(prefix)) {
throw new PluginFacingError(
`ctx.events.emit may only publish topics beginning with "${prefix}"`,
);
}
pluginEvents.emit(topic, payload);
return null;
}
private handleEventsSubscribe(runtime: PluginRuntime, topic: string): null {
if (runtime.subscriptions.has(topic)) return null;
runtime.subscriptions.add(topic);
const unsubscribe = pluginEvents.on(topic, (payload) => {
this.push(runtime, { kind: "event", key: topic, payload });
});
runtime.eventUnsubscribers.push(unsubscribe);
return null;
}
private handleHttpRoute(
runtime: PluginRuntime,
method: string,
routePath: string,
): null {
const verb = method.toUpperCase();
if (!HTTP_METHODS.has(verb)) {
throw new PluginFacingError(
`ctx.http.route does not support the ${verb} method`,
);
}
if (!routePath.startsWith("/")) {
throw new PluginFacingError(`ctx.http.route path must start with "/"`);
}
const key = `${verb} ${routePath}`;
if (
!runtime.routes.some((route) => `${route.method} ${route.path}` === key)
) {
runtime.routes.push({ method: verb, path: routePath });
}
this.deps.onRoutesChanged?.(runtime);
return null;
}
/**
* Invoked by the Express router this plugin's routes are mounted behind.
* Forwards the request into the worker and waits for the handler's reply.
*/
invokeRoute(
runtime: PluginRuntime,
key: string,
payload: unknown,
timeoutMs = HTTP_HANDLER_TIMEOUT_MS,
): Promise<unknown> {
if (runtime.disposed) {
return Promise.reject(new Error("Plugin is not running"));
}
const replyTo = this.nextPushId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.httpWaiters.delete(replyTo);
reject(new Error("Plugin route handler timed out"));
}, timeoutMs);
this.httpWaiters.set(replyTo, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
this.push(runtime, { kind: "http", key, payload, replyTo });
});
}
/**
* Gives a worker plugin a chance to close things down before it is killed.
* Resolves either way: a plugin that does not answer must not block the
* disable, it just loses its cleanup.
*/
requestDeactivate(
runtime: PluginRuntime,
timeoutMs = DEACTIVATE_TIMEOUT_MS,
): Promise<void> {
if (runtime.disposed) return Promise.resolve();
const replyTo = this.nextPushId++;
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.httpWaiters.delete(replyTo);
resolve();
}, timeoutMs);
const settle = () => {
clearTimeout(timer);
resolve();
};
this.httpWaiters.set(replyTo, { resolve: settle, reject: settle });
this.push(runtime, {
kind: "deactivate",
key: "deactivate",
payload: null,
replyTo,
});
});
}
/** Main thread -> worker, for events, timers and route invocations. */
push(runtime: PluginRuntime, message: PluginPush): void {
if (runtime.disposed) return;
try {
runtime.worker.postMessage(message);
} catch {
// Worker already gone.
}
}
/**
* Resolves the user a privileged ctx call acts as. This is where the two
* identity models that coexist in one plugin meet:
*
* - Inside an HTTP handler, `callerUserId` is set -- the worker attached
* it from the AsyncLocalStorage context it entered around that specific
* ctx.http.route invocation (see worker-bootstrap.ts). The call acts as
* whichever user's request is currently being served, so
* ctx.hosts.list() returns THEIR hosts, not the plugin installer's.
* Grants stay install-wide (an admin unlocks the capability for the
* plugin as a whole); this is only about whose DATA the call touches.
*
* - From a schedule.every timer or an events.on listener, there is no
* inbound request and so no AsyncLocalStorage context to read from --
* `callerUserId` is undefined. These fall back to ownerUserId, the user
* who last enabled the plugin, which is what background work has
* always run as.
*
* A plugin cannot influence which branch it gets: no ctx method takes a
* userId argument, so `callerUserId` only ever reflects a real inbound
* request the server's own auth middleware verified.
*/
private requireActor(runtime: PluginRuntime, callerUserId?: string): string {
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) {
throw new PluginFacingError(
`Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`,
);
}
return userId;
}
private async audit(
runtime: PluginRuntime,
method: string,
args: unknown[],
failure: Error | null,
callerUserId?: string,
): Promise<void> {
const userId = callerUserId ?? runtime.plugin.ownerUserId;
if (!userId) return;
// Attribution comes from the broker, never from the plugin: username is
// the plugin id, not the user's, so a plugin action is never mistaken for
// something the person did themselves. The userId this is logged against
// is whichever identity requireActor would have used for the same call
// (see its comment), so a request-triggered action is attributed to the
// requester, not always to whoever installed the plugin.
await logAudit({
userId,
username: `plugin:${runtime.plugin.id}`,
action: `plugin_${method.replace(/\./g, "_")}`,
resourceType: "plugin",
resourceId: runtime.plugin.id,
resourceName: runtime.plugin.manifest.name,
details: safeDetails(method, args),
success: failure === null,
errorMessage: failure?.message,
});
}
/** Resolves the display name for a plugin's owner, for UI surfaces. */
async ownerUsername(pluginId: string): Promise<string | null> {
const runtime = this.runtimes.get(pluginId);
if (!runtime?.plugin.ownerUserId) return null;
return getAuditUsername(runtime.plugin.ownerUserId);
}
newHandleId(): string {
return `sshconn:${randomUUID()}`;
}
}
/**
* Audit details are capped and only ever record the shape of a call, never a
* value a plugin passed. A plugin could otherwise write arbitrary volume, or
* something that looks like a real audit entry, into the trail.
*/
/**
* Errors the broker raises itself are safe to show a plugin: they are written
* here and say nothing about credentials. Anything else came from deeper in the
* server -- ssh2, the pool, a repository -- and can carry connect config,
* decrypted material or internal paths, so the plugin gets a generic message
* and the real one goes to the log.
*
* Marker class rather than message matching: a substring check would start
* leaking the moment an upstream error happened to contain a familiar phrase.
*/
export class PluginFacingError extends Error {
constructor(message: string) {
super(message);
this.name = "PluginFacingError";
}
}
function safeErrorMessage(method: string, error: Error): string {
if (error instanceof PluginFacingError) return error.message;
if (error instanceof PluginPermissionError) return error.message;
pluginLogger.error(`ctx.${method} failed`, error, {
operation: "plugin_ctx_error",
});
return `ctx.${method} failed. See the server log for details.`;
}
function storageKey(raw: unknown): string {
const key = String(raw ?? "");
if (!key) throw new PluginFacingError("ctx.storage requires a non-empty key");
if (key.length > MAX_STORAGE_KEY_LENGTH) {
throw new PluginFacingError(
`ctx.storage key exceeds the ${MAX_STORAGE_KEY_LENGTH} character limit`,
);
}
return key;
}
export function safeDetails(method: string, args: unknown[]): string {
const summary = args.map((arg) => {
if (typeof arg === "number" || typeof arg === "boolean") return arg;
if (typeof arg === "string") {
return arg.length > 64 ? `${arg.slice(0, 64)}…` : arg;
}
if (arg === null || arg === undefined) return null;
return Array.isArray(arg) ? `[array:${arg.length}]` : "[object]";
});
return JSON.stringify({ method, args: summary });
}
export { PluginPermissionError };
+471
View File
@@ -0,0 +1,471 @@
/**
* Builds the ctx a plugin's activate() receives.
*
* Every privileged member goes through guarded(), which checks the capability
* against the plugin's grants and writes an audit line naming the plugin and
* the acting user. That is the whole mechanism, and it is worth being precise
* about what it buys:
*
* - It stops accidental and casual overreach. A plugin that never declared
* credentials:read cannot reach a secret through ctx, and a plugin that
* declared it leaves a trail every time it does.
* - It does NOT contain malicious code. A plugin runs in the server process
* and can import any module core can. Guarding ctx does not change that,
* and this file does not pretend otherwise. Trust comes from signing,
* review and the kill list. See ARCHITECTURE.md.
*
* The actor never comes from plugin code. It comes from AsyncLocalStorage,
* set by a request or by ctx.asUser, so a plugin cannot name a user and be
* believed.
*/
import { pluginLogger } from "../utils/logger.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { assertCapability } from "./permissions.js";
import { getActor, runAsActor } from "./actor.js";
import { DisposableBag } from "./disposables.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend";
export type { PluginModule };
/** Caps mirroring what the old worker boundary enforced. */
const MAX_KV_KEY_LENGTH = 128;
const MAX_KV_VALUE_BYTES = 256 * 1024;
export interface PluginHandle {
module: PluginModule;
bag: DisposableBag;
providedServices: ServiceRegistration[];
offeredSecrets: SecretRegistration[];
}
export function createPluginHandle(
pluginId: string,
module: PluginModule,
): PluginHandle {
return {
module,
bag: new DisposableBag(pluginId),
providedServices: [],
offeredSecrets: [],
};
}
interface AuditOptions {
/** Audit action suffix, e.g. "kv_set". */
action: string;
/** Short description of the call shape. Never a value. */
details?: () => string;
}
/**
* Wraps a privileged function in the capability check and the audit line.
*
* The check runs before the call and the audit after, so a denied call is
* still recorded: "tried and was refused" is exactly the thing worth seeing.
*/
function guarded<Args extends unknown[], Result>(
manifest: PluginManifest,
capability: string,
fn: (...args: Args) => Promise<Result>,
options: AuditOptions,
): (...args: Args) => Promise<Result> {
return async (...args: Args): Promise<Result> => {
const pluginId = manifest.id;
try {
await assertCapability(pluginId, capability, manifest.capabilities);
} catch (error) {
await writeAudit(manifest, options, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
try {
const result = await fn(...args);
await writeAudit(manifest, options, { success: true });
return result;
} catch (error) {
await writeAudit(manifest, options, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
}
async function writeAudit(
manifest: PluginManifest,
options: AuditOptions,
outcome: { success: boolean; errorMessage?: string },
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
username: `plugin:${manifest.id}`,
action: `plugin_${options.action}`,
resourceType: "plugin",
resourceId: manifest.id,
resourceName: manifest.name,
details: options.details?.(),
success: outcome.success,
errorMessage: outcome.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
export function createPluginContext(
manifest: PluginManifest,
handle: PluginHandle,
): PluginContext {
const pluginId = manifest.id;
// Forced: a plugin cannot log as another plugin.
const logContext = { operation: `plugin:${pluginId}` };
const declared = manifest.capabilities;
const kvGet = guarded(
manifest,
"kv:own",
async (key: string) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const raw = await createCurrentPluginStorageRepository().get(
pluginId,
key,
);
if (raw === null) return null;
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
},
{ action: "kv_get" },
);
const kvSet = guarded(
manifest,
"kv:own",
async (key: string, value: unknown) => {
assertKvKey(key);
const serialized = JSON.stringify(value ?? null);
if (Buffer.byteLength(serialized, "utf8") > MAX_KV_VALUE_BYTES) {
throw new Error(
`Value for "${key}" exceeds the ${MAX_KV_VALUE_BYTES} byte limit for ctx.kv`,
);
}
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(
pluginId,
key,
serialized,
);
},
{ action: "kv_set" },
);
const kvDelete = guarded(
manifest,
"kv:own",
async (key: string) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
{ action: "kv_delete" },
);
const kvList = guarded(
manifest,
"kv:own",
async () => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
{ action: "kv_list" },
);
return {
pluginId,
manifest,
log: {
debug: (message) => pluginLogger.debug(message, logContext),
info: (message) => pluginLogger.info(message, logContext),
warn: (message) => pluginLogger.warn(message, logContext),
error: (message, error) => pluginLogger.error(message, error, logContext),
},
events: {
/**
* A plugin may only emit under its own namespace unless it holds
* events:core. Without this an in-process plugin can publish
* "host.status" and drive the automations engine as if core had.
*/
emit: (topic, payload) => {
if (
!topic.startsWith(`plugin.${pluginId}.`) &&
!declared.includes("events:core")
) {
throw new Error(
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
`Declare the events:core capability to emit core topics.`,
);
}
pluginEvents.emit(topic, payload);
},
on: (topic, listener) => {
const unsubscribe = pluginEvents.on(topic, listener);
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
return unsubscribe;
},
},
kv: {
get: (key) => kvGet(key),
set: (key, value) => kvSet(key, value),
delete: (key) => kvDelete(key),
list: () => kvList(),
},
registry: {
provide: (key, value) => {
registry.provide(key, value);
handle.bag.add(
() => void registry.revoke(key, value),
`registry key "${key}"`,
);
},
consume: (key) => registry.consume(key),
revoke: (key, value) => registry.revoke(key, value),
},
services: {
provide: (service, implementation) => {
// Declared AND provided: a plugin cannot publish a service its
// manifest never mentioned.
const entry = manifest.provides?.find(
(candidate) => candidate.service === service,
);
if (!entry) {
throw new Error(
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
);
}
const registration = serviceRegistry.provideService({
service,
version: entry.version,
permission: entry.permission,
pluginId,
pluginName: manifest.name,
implementation: implementation as Record<string, unknown>,
});
handle.providedServices.push(registration);
handle.bag.add(
() => void serviceRegistry.revokeService(service, registration),
`service "${service}"`,
);
},
get: (service, options) =>
serviceRegistry.createServiceHandle(service, pluginId, {
// A caller-supplied userId is only honoured when it matches the
// actor; otherwise the ambient actor wins. A plugin cannot widen
// its reach by naming someone else here.
resolveUserId: () => options?.userId ?? getActor(),
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
}),
},
secrets: {
offer: (key, resolve) => {
const entry = manifest.providesSecret?.find(
(candidate) => candidate.key === key,
);
if (!entry) {
throw new Error(
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
);
}
const registration = secretRegistry.offerSecret({
pluginId,
pluginName: manifest.name,
key,
permission: entry.permission,
resolve,
});
handle.offeredSecrets.push(registration);
handle.bag.add(
() => void secretRegistry.withdrawSecret(pluginId, key, registration),
`secret "${key}"`,
);
},
withdraw: (key) => {
const registration = handle.offeredSecrets.find(
(entry) => entry.key === key,
);
handle.offeredSecrets = handle.offeredSecrets.filter(
(entry) => entry !== registration,
);
return secretRegistry.withdrawSecret(pluginId, key, registration);
},
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId ?? getActor(),
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
},
disposables: {
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
},
/**
* Background work acts as a named user. Always audited, because "this ran
* as someone" is exactly the thing an operator needs to be able to see.
*/
asUser: async (userId, fn) => {
if (typeof userId !== "string" || userId.length === 0) {
throw new Error(
`Plugin ${pluginId} called ctx.asUser without a user id`,
);
}
await writeAudit(
manifest,
{
action: "as_user",
details: () => `${pluginId} ran background work as ${userId}`,
},
{ success: true },
);
return runAsActor(userId, "asUser", () => Promise.resolve(fn()));
},
currentActor: () => getActor(),
};
}
function assertKvKey(key: string): void {
if (typeof key !== "string" || key.length === 0) {
throw new Error("ctx.kv keys must be non-empty strings");
}
if (key.length > MAX_KV_KEY_LENGTH) {
throw new Error(
`ctx.kv keys must be at most ${MAX_KV_KEY_LENGTH} characters`,
);
}
}
async function checkPermission(
userId: string,
permission: string,
): Promise<boolean> {
const { PermissionManager } = await import("../utils/permission-manager.js");
return PermissionManager.getInstance().hasPermission(userId, permission);
}
/** Attribution comes from the runtime; details only record the call shape. */
async function writeServiceAudit(
entry: serviceRegistry.ServiceAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
resourceType: "plugin",
resourceId: entry.registration.pluginId,
resourceName: entry.registration.pluginName,
details: `${entry.consumerPluginId} called ${entry.registration.service}.${entry.method}`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Records that a secret was borrowed, never what it was. `resolved` is the
* only thing said about the value: whether one came back at all.
*/
async function writeSecretAudit(
entry: secretRegistry.SecretAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: "plugin_secret_shared_read",
resourceType: "plugin",
resourceId: entry.providerPluginId,
resourceName: entry.providerPluginName,
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Undoes everything the plugin registered, then runs its own deactivate().
*
* The plugin's cleanup runs last so it can still emit or read while shutting
* down, and its throwing does not stop the bag from being emptied.
*/
export async function disposePluginHandle(
handle: PluginHandle,
pluginId: string,
options: { runDeactivate?: boolean } = {},
): Promise<void> {
await handle.bag.disposeAll();
handle.providedServices = [];
handle.offeredSecrets = [];
// Belt and braces: an offer made outside ctx, or one whose registration was
// lost to a throw mid-activate, would otherwise outlive the plugin.
secretRegistry.withdrawAllForPlugin(pluginId);
// Skipped when activate() never finished: a plugin's deactivate expects the
// state activate builds, and calling it on a half-built plugin tends to
// throw over the original error and hide it.
const runDeactivate = options.runDeactivate ?? true;
if (runDeactivate && typeof handle.module.deactivate === "function") {
try {
await handle.module.deactivate();
} catch (error) {
pluginLogger.error(
`Plugin ${pluginId} deactivate() failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_deactivate" },
);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Everything a plugin registered, so deactivate can undo all of it.
*
* The rule this enforces: a plugin is only as disableable as its cleanup. A
* listening port, a live SSH session or an interval that outlives deactivate
* turns "disabled" into a lie, and re-enabling then fails on a port that is
* still held.
*
* Disposal runs in reverse registration order, so a thing torn down last was
* set up first. Every disposer is isolated: one that throws is logged and the
* rest still run, because a half-disposed plugin is worse than a noisy log.
*/
import { pluginLogger } from "../utils/logger.js";
export type Disposer = () => void | Promise<void>;
interface Entry {
dispose: Disposer;
/** What this is, for the log line when it throws. */
label: string;
}
export class DisposableBag {
private entries: Entry[] = [];
private disposed = false;
constructor(private readonly pluginId: string) {}
add(dispose: Disposer, label = "disposable"): void {
if (this.disposed) {
// Registering after teardown means the resource would never be cleaned
// up. Dispose it immediately rather than silently leaking it.
void this.run({ dispose, label });
return;
}
this.entries.push({ dispose, label });
}
get size(): number {
return this.entries.length;
}
/**
* Runs every disposer, newest first. Safe to call more than once, and never
* throws: the caller is already tearing the plugin down.
*/
async disposeAll(): Promise<void> {
this.disposed = true;
const entries = this.entries.reverse();
this.entries = [];
for (const entry of entries) {
await this.run(entry);
}
}
private async run(entry: Entry): Promise<void> {
try {
await entry.dispose();
} catch (error) {
pluginLogger.error(
`Plugin ${this.pluginId} failed to dispose ${entry.label}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_dispose" },
);
}
}
}
-152
View File
@@ -1,152 +0,0 @@
/**
* The plugins that run in-process on the main thread instead of in a worker.
*
* ## Why this list is hardcoded
*
* Every other plugin is handed a `ctx` object and nothing else, across a
* structured-clone postMessage boundary. That boundary is what makes the
* capability gate and the audit trail meaningful: a worker plugin physically
* cannot hold a db handle, an ssh2 Client or a live socket, so every privileged
* action has to pass through the broker, which checks grants first and writes
* an audit line after.
*
* A plugin in this tier opts out of all of that. It is imported directly into
* the server process, so it has the same reach as any other backend module: no
* ctx boundary, no capability gate, no audit interception, no containment. It
* is Termix's own code that happens to be packaged and lifecycled as a plugin.
*
* That is only defensible because membership cannot be claimed. This set is
* compiled into the server. A manifest cannot opt in -- declaring the
* capability without being listed here is a validation error, not a silent
* downgrade (see parseManifest). Nothing read from the database, a registry or
* a plugin directory can add to it.
*
* DO NOT add an installed or community plugin here. If a plugin needs more
* reach than ctx allows, the answer is to widen ctx -- deliberately, with a
* gate and an audit line -- not to move it onto the main thread.
*/
/**
* The only plugin ids that may run in-process.
*
* ssh-terminal is here because it IS the SSH transport rather than a consumer
* of it: it owns a WebSocket server and long-lived ssh2 clients, neither of
* which can cross a postMessage boundary.
*
* docker is here for the same reason: its container console owns a
* WebSocket server with long-lived ssh2 clients and PTY streams, its
* session manager reuses a live ssh2.Client across many REST calls, and its
* SSH connect flow implements TOTP/Warpgate as a stateful multi-request
* handshake the worker ctx.ssh API has no hook for.
*
* host-metrics is here for the same reason: its polling manager holds
* long-lived ssh2 clients across a whole fleet of hosts, reused between
* polling ticks rather than opened per request, and its connect flow
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
* keyboard-interactive handshakes the terminal does -- none of which the
* worker ctx.ssh API (connect + single exec-and-collect) has a hook for.
*
* ai is here for a different reason: it does not own a transport at all, but
* it writes directly across hosts, snippets, fleets, alert rules and
* automations through their repositories, runs approved commands over the
* shared SSH pool via resolveHostById and withConnection, and streams its
* chat replies as server-sent events. ctx.hosts is a 13-field read-only view
* with no write methods and no snippets/fleets/alerts/automations surface at
* all, and ctx.http.route replies with one postMessage value rather than a
* stream, so none of that fits the worker ctx either.
*
* proxmox is here for the same reason as ai: it does not own a transport,
* but it writes directly across hosts through the host repository during
* discovery and sync, resolves and connects to hosts via resolveHostById and
* createJumpHostChain the same way the terminal and ai's command executor
* do, verifies host keys via SSHHostKeyVerifier, and streams its discovery
* progress as server-sent events. Neither the worker's 13-field read-only
* ctx.hosts nor its single-postMessage-reply ctx.http.route can support that.
*
* remote-desktop is here for the same reason ssh-terminal is: it owns a
* WebSocket server (guacamole-lite, port 30008) and its
* /connect-host/:hostId route does multi-step credential/tunnel resolution
* (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw
* net.Socket plumbing that cannot cross a structured-clone boundary.
*
* fleets is here for the same reason as ai and proxmox: it does not own a
* transport, but its execute/transfer routes resolve and connect to many
* hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP
* reads/writes and a zip archive for its push/pull routes, and buffer
* multipart file uploads. None of that fits the worker's single-exec
* ctx.ssh or single-postMessage-reply ctx.http.route.
*
* automations is here for the same reason as ai, proxmox and fleets: it does
* not own a transport, but its engine and scheduler write across hosts,
* snippets, fleets and notification-channel repositories through the shared
* SSH pool via resolveHostById and withConnection, and its scheduler needs
* direct repository access plus subscription to the cross-plugin event bus
* (host metrics, status and health-check events) that the worker's read-only
* ctx.hosts and single-postMessage-reply ctx.http.route cannot support.
*
* network-topology is here for a different reason than the rest of this
* list: its two routes are plain per-user JSON CRUD against a single
* repository, and would fit the worker ctx fine on their own. It stays
* in-process to keep its existing /network-topology URL and its existing
* dedicated table (network_topology, keyed by userId) rather than moving
* onto /plugin-api/network-topology/* and the generic key-value
* ctx.storage the worker tier would require -- a URL and storage-model
* change, not a capability gap.
*
* tailscale is here because its /devices route makes an outbound HTTP call
* to the Tailscale (or Headscale) Admin API using a settings-repo API key.
* The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
* worker-tier plugin to make an outbound HTTP request.
*
* web-endpoint is here for the same reason ssh-terminal is, on a smaller
* scale: its open route polls the tunnel manager's live maps
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a
* TunnelConfig from plaintext host credentials that ctx.hosts deliberately
* withholds, and hands the resulting live ssh2.Client to forwardOut to probe
* the target before reporting success. A structured-clone boundary can carry
* none of those three, so this is a genuine transport dependency rather than
* the URL-stability concession network-topology and workspaces make.
*
* workspaces is here for the same reason network-topology is: its routes are
* plain per-user JSON CRUD against a single repository, and would fit the
* worker ctx fine on their own. It stays in-process to keep its existing
* /workspaces URL and its existing dedicated table (user_workspaces, keyed
* by userId) rather than moving onto /plugin-api/workspaces/* and the
* generic key-value ctx.storage the worker tier would require -- a URL and
* storage-model change, not a capability gap.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
"ai",
"proxmox",
"remote-desktop",
"fleets",
"automations",
"network-topology",
"tailscale",
"workspaces",
"web-endpoint",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
export function isFirstParty(pluginId: string): boolean {
return FIRST_PARTY_PLUGIN_IDS.has(pluginId);
}
/**
* Both conditions are required. The allowlist alone is not enough -- a
* first-party plugin that does not declare the capability still runs in a
* worker, so the manifest stays an honest description of what the plugin does.
*/
export function runsInProcess(
pluginId: string,
permissions: readonly string[],
): boolean {
return (
isFirstParty(pluginId) && permissions.includes(TRANSPORT_OWNER_CAPABILITY)
);
}
-373
View File
@@ -1,373 +0,0 @@
/**
* The ctx handed to an in-process first-party plugin.
*
* Unlike the worker ctx built in worker-bootstrap.ts, nothing here is a
* postMessage round trip and nothing is capability-gated. There would be no
* point: the plugin is running on the main thread and could import any module
* in the server directly. A gate it can trivially bypass is a false claim, not
* a control, so this surface does not pretend to be one.
*
* What this ctx is actually for is lifecycle and identity: the plugin gets a
* logger tagged with its id, the shared event bus, its own storage namespace,
* and the service registry. It uses ordinary imports for everything else.
*
* See first-party.ts for why membership in this tier cannot be self-declared.
*/
import { pluginLogger } from "../utils/logger.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { safeDetails } from "./broker.js";
import type { PluginManifest } from "./manifest.js";
export interface InProcessPluginContext {
pluginId: string;
manifest: PluginManifest;
log: {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: Error) => void;
};
events: {
emit: (topic: string, payload: unknown) => void;
on: (topic: string, listener: (payload: unknown) => void) => () => void;
};
storage: {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
};
registry: {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
};
/**
* Declared service contracts between plugins. Unlike `registry` above, a
* service must be declared in the manifest and every call through a handle
* is checked against the acting user's role permissions.
*/
services: {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(
service: string,
options?: { userId?: string },
) => T;
};
/**
* Secrets shared between plugins by reference.
*
* `offer` publishes a resolver, never a value, so the provider keeps the
* ability to rotate or clear it at any time. `getShared` reads another
* plugin's offered secret for one call, gated by that provider's own role
* permission. See secret-registry.ts.
*/
secrets: {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
};
}
/**
* Tracks what a plugin registered so deactivate can undo it. A first-party
* plugin owns real resources -- a listening port, live SSH sessions -- so
* "disabled" has to actually release them, not just stop routing to them.
*/
export interface InProcessHandle {
module: PluginModule;
/** Event unsubscribers, run on deactivate so no listener outlives the plugin. */
unsubscribers: Array<() => void>;
/** Registry keys this plugin provided, revoked on deactivate. */
providedKeys: Array<{ key: string; value: unknown }>;
/** Declared services this plugin provided, revoked on deactivate. */
providedServices: ServiceRegistration[];
/** Shared secrets this plugin offered, withdrawn on deactivate. */
offeredSecrets: SecretRegistration[];
}
export interface PluginModule {
activate: (ctx: InProcessPluginContext) => void | Promise<void>;
deactivate?: () => void | Promise<void>;
}
export function createInProcessContext(
manifest: PluginManifest,
handle: InProcessHandle,
): InProcessPluginContext {
const pluginId = manifest.id;
// Forced, like the worker path: a plugin cannot log as another plugin.
const context = { operation: `plugin:${pluginId}` };
return {
pluginId,
manifest,
log: {
debug: (message) => pluginLogger.debug(message, context),
info: (message) => pluginLogger.info(message, context),
warn: (message) => pluginLogger.warn(message, context),
error: (message, error) => pluginLogger.error(message, error, context),
},
events: {
emit: (topic, payload) => pluginEvents.emit(topic, payload),
on: (topic, listener) => {
const unsubscribe = pluginEvents.on(topic, listener);
handle.unsubscribers.push(unsubscribe);
return unsubscribe;
},
},
storage: {
get: async (key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const raw = await createCurrentPluginStorageRepository().get(
pluginId,
key,
);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
set: async (key, value) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(
pluginId,
key,
JSON.stringify(value ?? null),
);
},
delete: async (key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
list: async () => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
},
registry: {
provide: (key, value) => {
registry.provide(key, value);
handle.providedKeys.push({ key, value });
},
consume: (key) => registry.consume(key),
revoke: (key, value) => registry.revoke(key, value),
},
services: {
provide: (service, implementation) => {
// Declared AND provided, the same shape the capability gate uses: a
// plugin cannot publish a service its manifest never mentioned.
const declared = manifest.provides?.find(
(entry) => entry.service === service,
);
if (!declared) {
throw new Error(
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
);
}
const registration = serviceRegistry.provideService({
service,
version: declared.version,
permission: declared.permission,
pluginId,
pluginName: manifest.name,
implementation: implementation as Record<string, unknown>,
});
handle.providedServices.push(registration);
},
get: (service, options) =>
serviceRegistry.createServiceHandle(service, pluginId, {
resolveUserId: () => options?.userId,
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
}),
},
secrets: {
offer: (key, resolve) => {
// Declared AND offered, the same rule services.provide enforces.
const declared = manifest.providesSecret?.find(
(entry) => entry.key === key,
);
if (!declared) {
throw new Error(
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
);
}
const registration = secretRegistry.offerSecret({
pluginId,
pluginName: manifest.name,
key,
permission: declared.permission,
resolve,
});
handle.offeredSecrets.push(registration);
},
withdraw: (key) => {
const registration = handle.offeredSecrets.find(
(entry) => entry.key === key,
);
handle.offeredSecrets = handle.offeredSecrets.filter(
(entry) => entry !== registration,
);
return secretRegistry.withdrawSecret(pluginId, key, registration);
},
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId,
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
},
};
}
async function checkPermission(
userId: string,
permission: string,
): Promise<boolean> {
const { PermissionManager } = await import("../utils/permission-manager.js");
return PermissionManager.getInstance().hasPermission(userId, permission);
}
/**
* Mirrors PluginBroker.audit: attribution comes from the runtime, never from
* the plugin, and the details only ever record the shape of a call.
*/
async function writeServiceAudit(
entry: serviceRegistry.ServiceAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
resourceType: "plugin",
resourceId: entry.registration.pluginId,
resourceName: entry.registration.pluginName,
details: safeDetails(
`${entry.registration.service}.${entry.method}`,
entry.args,
),
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/**
* Records that a secret was borrowed, never what it was.
*
* `resolved` is the only thing said about the value: whether one came back at
* all. That is what makes the trail useful (you can see a consumer running on
* someone else's key) without the trail itself becoming a place the secret
* leaks to.
*/
async function writeSecretAudit(
entry: secretRegistry.SecretAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: "plugin_secret_shared_read",
resourceType: "plugin",
resourceId: entry.providerPluginId,
resourceName: entry.providerPluginName,
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
success: entry.success,
errorMessage: entry.errorMessage,
});
} catch {
// Auditing must never break the caller.
}
}
/** Undoes everything the ctx handed out. Safe to call more than once. */
export async function disposeInProcessHandle(
handle: InProcessHandle,
pluginId: string,
): Promise<void> {
for (const { key, value } of handle.providedKeys) {
registry.revoke(key, value);
}
handle.providedKeys = [];
// Taking the services with it is what makes a consumer's held handle start
// throwing PluginServiceUnavailableError rather than calling a dead provider.
for (const registration of handle.providedServices) {
serviceRegistry.revokeService(registration.service, registration);
}
handle.providedServices = [];
// Withdrawing the offers is what turns a borrower's getShared into a plain
// null instead of a handle pointing at a resolver whose plugin is gone. The
// sweep afterwards is belt and braces: an offer made outside the ctx, or one
// whose push was lost to a crash mid-activate, would otherwise outlive the
// plugin that backs it.
for (const registration of handle.offeredSecrets) {
secretRegistry.withdrawSecret(
registration.pluginId,
registration.key,
registration,
);
}
handle.offeredSecrets = [];
secretRegistry.withdrawAllForPlugin(pluginId);
for (const unsubscribe of handle.unsubscribers) {
try {
unsubscribe();
} catch {
// Already detached.
}
}
handle.unsubscribers = [];
// The plugin's own cleanup runs last: it may still want to emit or read
// while shutting down.
if (typeof handle.module.deactivate === "function") {
try {
await handle.module.deactivate();
} catch (error) {
pluginLogger.error(
`Plugin ${pluginId} deactivate() failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_deactivate" },
);
}
}
}
-108
View File
@@ -1,108 +0,0 @@
/**
* Turns a plugin's registered routes into an Express router mounted on the
* existing /plugin-api/:pluginId dispatcher.
*
* The plugin never sees the Express req/res objects. It gets a plain,
* structured-cloneable summary of the request, and returns a plain object that
* this module turns back into a response. That keeps sockets, headers carrying
* session cookies, and the rest of the server's request context on this side of
* the boundary.
*/
import express, { type Request, type Response, type Router } from "express";
import { pluginLogger } from "../utils/logger.js";
import type { PluginBroker, PluginRuntime } from "./broker.js";
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
/** Headers a plugin is allowed to see. Anything carrying auth is withheld. */
const FORWARDED_HEADERS = ["content-type", "accept", "user-agent"];
export function buildPluginRouter(
broker: PluginBroker,
runtime: PluginRuntime,
): Router {
const router = express.Router();
router.use(express.json({ limit: "1mb" }));
for (const route of runtime.routes) {
const key = `${route.method} ${route.path}`;
const method = route.method.toLowerCase() as
"get" | "post" | "put" | "patch" | "delete";
router[method](route.path, async (req: Request, res: Response) => {
try {
const result = await broker.invokeRoute(runtime, key, {
method: route.method,
path: req.path,
params: req.params,
query: req.query,
body: req.body ?? null,
headers: pickHeaders(req),
// The acting user, verified by authenticateJWT ahead of the
// /plugin-api mount in database.ts. A plugin cannot set or spoof this.
userId: (req as Request & { userId?: string }).userId ?? null,
});
sendPluginResponse(res, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.error(
`Plugin ${runtime.plugin.id} route ${key} failed`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_http" },
);
res.status(502).json({ error: "Plugin route failed", detail: message });
}
});
}
return router;
}
function pickHeaders(req: Request): Record<string, string> {
const headers: Record<string, string> = {};
for (const name of FORWARDED_HEADERS) {
const value = req.headers[name];
if (typeof value === "string") headers[name] = value;
}
return headers;
}
function sendPluginResponse(res: Response, result: unknown): void {
if (result === null || result === undefined) {
res.status(204).end();
return;
}
const shape = result as {
status?: unknown;
body?: unknown;
headers?: Record<string, unknown>;
};
// An envelope is only recognised when `status` is present. Keying off `body`
// alone would swallow an ordinary payload that happens to have a body field,
// which is a perfectly normal thing for a handler to return.
const hasEnvelope =
typeof shape === "object" && shape !== null && "status" in shape;
const status = hasEnvelope ? Number(shape.status ?? 200) : 200;
const body = hasEnvelope ? (shape.body ?? null) : result;
if (!Number.isInteger(status) || status < 100 || status > 599) {
res.status(502).json({ error: "Plugin returned an invalid status code" });
return;
}
const serialised = JSON.stringify(body);
if (
serialised &&
Buffer.byteLength(serialised, "utf8") > MAX_RESPONSE_BYTES
) {
res.status(502).json({ error: "Plugin response exceeded the size limit" });
return;
}
res.status(status).json(body);
}
+215 -251
View File
@@ -1,170 +1,58 @@
/**
* Composition root for the plugin runtime: wires the loader, the broker and the
* /plugin-api dispatcher together and exposes the handful of calls the rest of
* the server needs.
* Composition root for the plugin runtime.
*
* The heavy dependencies (host resolution, the SSH pool, the repositories) are
* imported lazily inside the dep functions rather than at module scope. Several
* of them pull in the repository layer, and a static import here would create
* the same cycle hosts/automation-events.ts documents.
* Owns the loader, seeds the plugins table, keeps capability grants in step
* with each manifest, and registers the RBAC permissions a plugin contributes.
*
* The heavy dependencies are imported lazily inside each function rather than
* at module scope: several pull in the repository layer, and a static import
* here would close the cycle hosts/automation-events.ts documents.
*/
import { pluginLogger } from "../utils/logger.js";
import {
registerPluginRouter,
unregisterPluginRouter,
} from "../database/routes/plugin-api-routes.js";
import { PluginBroker, type PluginRuntime } from "./broker.js";
import { buildPluginRouter } from "./http-bridge.js";
import { unregisterPluginRouter } from "../database/routes/plugin-api-routes.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import { isFirstParty, runsInProcess } from "./first-party.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
let loader: PluginLoader | null = null;
let broker: PluginBroker | null = null;
function createBroker(): PluginBroker {
return new PluginBroker({
listHosts: async (userId) => {
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
// listByUserId, not listDecryptedByUserId: a plugin only ever gets host
// metadata, so there is no reason to decrypt secrets just to drop them.
const hosts = await createCurrentHostRepository().listByUserId(userId);
return hosts as unknown as Record<string, unknown>[];
},
resolveHost: async (hostId, userId) => {
// resolveHostById is the access gate and the decryption step in one. The
// object it returns carries plaintext secrets, so it must never leave
// this closure -- the broker maps it through toPluginHostView first.
const { resolveHostById } = await import("../hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
return host as unknown as Record<string, unknown> | null;
},
storage: {
get: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().get(pluginId, key);
},
set: async (pluginId, key, value) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginStorageRepository().set(pluginId, key, value);
},
delete: async (pluginId, key) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().delete(pluginId, key);
},
listKeys: async (pluginId) => {
const { createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginStorageRepository().listKeys(pluginId);
},
},
ssh: {
/**
* The single most security-sensitive function in the plugin runtime.
*
* `host` below is the object resolveHostById returns: it carries the
* plaintext password, private key and key passphrase. It is a local
* inside this closure and is referenced only by createFleetSshFactory.
* Nothing derived from it is returned. The caller gets two closures --
* release and exec -- and exec resolves to stdout/stderr/code strings
* only, so there is no path from the worker back to the credential.
*/
open: async (hostId, userId) => {
const [
{ resolveHostById },
{ connectionPool },
{ createFleetSshFactory, getFleetPoolKey },
{ execCommand },
] = await Promise.all([
import("../hosts/host-resolver.js"),
import("../hosts/ssh-connection-pool.js"),
import("../hosts/ssh-client-factory.js"),
import("../hosts/metrics-shared/common-utils.js"),
]);
// resolveHostById performs the RBAC check (canAccessHost) and returns
// null for both "missing" and "forbidden".
const host = await resolveHostById(hostId, userId);
if (!host) {
throw new Error(`Host ${hostId} not found or not accessible`);
}
const poolKey = getFleetPoolKey(host);
const client = await connectionPool.getConnection(
poolKey,
createFleetSshFactory(host),
);
return {
release: () => connectionPool.releaseConnection(poolKey, client),
exec: (command, timeoutMs) => execCommand(client, command, timeoutMs),
};
},
},
onRoutesChanged: (runtime) => remountRoutes(runtime),
});
}
function remountRoutes(runtime: PluginRuntime): void {
if (runtime.routes.length === 0) return;
registerPluginRouter(runtime.plugin.id, buildPluginRouter(broker!, runtime));
}
export function getPluginRuntime(): {
loader: PluginLoader;
broker: PluginBroker;
} {
if (!loader || !broker) {
broker = createBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
unregisterPluginRouter(plugin.id);
},
onBeforeTerminate: async (plugin) => {
const runtime = broker!.get(plugin.id);
if (runtime) await broker!.requestDeactivate(runtime);
},
});
export function getPluginRuntime(): { loader: PluginLoader } {
if (!loader) {
loader = new PluginLoader();
}
return { loader, broker };
return { loader };
}
/**
* Makes sure a bundled first-party plugin has a row so it can be enabled and
* disabled like any other.
* Gives every discovered plugin a row, and refreshes the stored manifest.
*
* Only the insert sets state: once the row exists, whatever the user chose
* wins. Re-enabling a plugin they disabled on every restart would be a bug,
* not a default.
* Bundled plugins start enabled because they are part of the install; a
* plugin dropped into the data directory starts disabled, so arriving on disk
* is never the same as being allowed to run.
*
* `state` is only ever set on insert. Once the row exists whatever the user
* chose wins, because re-enabling a plugin they disabled on every restart
* would be a bug rather than a default.
*/
async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise<void> {
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
if (!isFirstParty(plugin.id)) continue;
const manifestJson = JSON.stringify(plugin.manifest);
const existing = await repository.findById(plugin.id);
if (existing) {
// Keep metadata fresh across upgrades, but never touch `state`.
if (existing.version !== plugin.manifest.version) {
await repository.update(plugin.id, {
version: plugin.manifest.version,
name: plugin.manifest.name,
manifestJson: JSON.stringify(plugin.manifest),
});
}
// Unconditionally, not just on a version change: a manifest edited
// without a version bump used to leave the stored copy stale forever,
// and the API and the grant check both read the stored copy.
await repository.update(plugin.id, {
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
manifestJson,
});
continue;
}
@@ -172,21 +60,64 @@ async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise<void> {
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
tier: "first-party",
source: "bundled",
state: "enabled",
manifestJson: JSON.stringify(plugin.manifest),
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
manifestJson,
});
pluginLogger.info(`Registered bundled plugin ${plugin.id}`, {
pluginLogger.info(`Registered ${plugin.source} plugin ${plugin.id}`, {
operation: "plugin_seed",
});
}
}
/**
* Loads every plugin on disk and activates the ones marked enabled in the
* database. Called once from the backend start-up sequence.
* Brings plugin_permission_grants in line with the manifests on disk.
*
* A capability the manifest no longer declares is revoked: leaving it granted
* would mean an upgrade silently kept a permission the new version never
* asked for. Bundled plugins have every declared capability granted
* automatically, since shipping in the install is the consent.
*/
async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginPermissionGrantRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginPermissionGrantRepository();
for (const plugin of loaded) {
const declared = new Set(plugin.manifest.capabilities);
const existing = await repository.listByPlugin(plugin.id);
for (const grant of existing) {
if (declared.has(grant.capability)) continue;
await repository.revoke(plugin.id, grant.capability);
pluginLogger.info(
`Revoked ${plugin.id} capability "${grant.capability}": the manifest no longer declares it`,
{ operation: "plugin_grants" },
);
}
if (plugin.source !== "bundled") continue;
const granted = new Set(existing.map((grant) => grant.capability));
for (const capability of declared) {
if (granted.has(capability)) continue;
await repository.grant({
pluginId: plugin.id,
capability,
grantedBy: null,
source: "bundled",
});
}
invalidatePluginPermissionCache(plugin.id);
}
}
/**
* Loads every plugin on disk and activates the ones marked enabled, in
* dependency order. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
@@ -194,110 +125,134 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
await seedBundledPlugins(loaded);
const {
createCurrentPluginRepository,
createCurrentPluginPermissionGrantRepository,
} = await import("../database/repositories/factory.js");
await seedPlugins(loaded);
await syncCapabilityGrants(loaded);
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const records = await createCurrentPluginRepository().listAll();
const byId = new Map(records.map((record) => [record.id, record]));
const enabled = new Set(
records
.filter((record) => record.state === "enabled")
.map((record) => record.id),
);
for (const plugin of loaded) {
const record = byId.get(plugin.id);
if (!record || record.state !== "enabled") continue;
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.map((plugin) => plugin.id);
// An in-process first-party plugin never uses the gated ctx, so it has no
// owner to act as and must not be held to the grant requirement below.
if (runsInProcess(plugin.id, plugin.manifest.permissions)) {
try {
await activatePlugin(plugin.id);
} catch (error) {
pluginLogger.error(
`Failed to activate plugin ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_activate" },
);
}
continue;
}
for (const pluginId of candidates) {
const plugin = pluginLoader.get(pluginId);
if (plugin) await registerPluginPermissions(plugin);
}
// The plugins table records no installer, so the plugin acts under the
// authority of whoever granted its capabilities. Without a grant there is
// no owner and nothing the plugin could do with one, so it stays stopped.
const grants =
await createCurrentPluginPermissionGrantRepository().listByPlugin(
plugin.id,
);
const ownerUserId = grants[0]?.grantedBy;
const result = await pluginLoader.activateAll(candidates);
await persistRuntimeState(loaded);
if (!ownerUserId) {
pluginLogger.warn(
`Plugin ${plugin.id} is enabled but has no capability grants, leaving it stopped`,
{ operation: "plugin_activate" },
);
continue;
}
try {
await activatePlugin(plugin.id, ownerUserId);
} catch (error) {
pluginLogger.error(
`Failed to activate plugin ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_activate" },
);
}
if (result.blocked.size > 0 || result.failed.size > 0) {
pluginLogger.warn(
`${result.blocked.size} plugin(s) blocked, ${result.failed.size} failed to activate`,
{ operation: "plugin_init" },
);
}
return loaded;
}
/** Mirrors loader state into the database so the admin API can report it. */
async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
for (const plugin of loaded) {
if (plugin.state !== "blocked" && plugin.state !== "failed") continue;
try {
await repository.update(plugin.id, {
state: plugin.state,
lastError: plugin.lastError,
});
} catch {
// Reporting state must never stop the boot.
}
}
}
/**
* Puts a plugin's declared permissions into the role catalog.
*
* Until this ran, contributes.permissionGroup was validated but never
* consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id
* rejects any string isValidPermission does not know. Registering here is what
* makes a plugin permission appear in the admin role editor exactly like
* hosts.view does.
* Until this runs a plugin permission cannot be granted at all, because
* PUT /rbac/roles/:id rejects any string isValidPermission does not know.
* Registering here is what makes one appear in the admin role editor exactly
* like hosts.view does.
*/
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const group = plugin.manifest.contributes?.permissionGroup;
if (!group) return;
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
});
try {
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
});
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group.defaultForRole);
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group);
}
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${plugin.id}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_permissions" },
);
}
}
/**
* Applies the plugin's declared role suggestion.
* Applies a plugin's declared role suggestion, once.
*
* Additive only, and only for a permission the role does not already list.
* An admin's later revoke must never be re-granted on the next restart, which
* is the same rule ensureSystemRoles follows for the built-in roles.
* Only permissions the plugin declares itself are eligible; the manifest
* validator enforces that, and this re-checks it because the stored manifest
* is data. Without it a manifest could add admin.* to any role at boot.
*
* Each default is applied at most once and the fact is recorded, so an admin
* who later revokes it does not get it handed back on the next restart. The
* old code claimed that behaviour in a comment but re-added the permission
* every time.
*/
async function applyRoleDefaults(
pluginId: string,
defaults: Record<string, string[]>,
group: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
},
): Promise<void> {
const { createCurrentRoleRepository } =
const { createCurrentRoleRepository, createCurrentPluginStorageRepository } =
await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const repository = createCurrentRoleRepository();
for (const [roleName, wanted] of Object.entries(defaults)) {
const roleRepository = createCurrentRoleRepository();
const storage = createCurrentPluginStorageRepository();
const APPLIED_KEY = "__role_defaults_applied";
let applied: string[] = [];
try {
const raw = await storage.get(pluginId, APPLIED_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) applied = parsed;
} catch {
applied = [];
}
const declared = new Set(group.permissions);
const appliedSet = new Set(applied);
for (const [roleName, wanted] of Object.entries(group.defaultForRole ?? {})) {
try {
const role = await repository.findRoleByName(roleName);
const role = await roleRepository.findRoleByName(roleName);
if (!role) continue;
let current: unknown;
@@ -308,19 +263,33 @@ async function applyRoleDefaults(
}
if (!Array.isArray(current)) continue;
// A wildcard the role already holds covers these, so adding them would
// only be noise in the editor.
const missing = wanted.filter(
(permission) => !coveredBy(current as string[], permission),
);
const missing = wanted.filter((permission) => {
if (!declared.has(permission)) {
pluginLogger.warn(
`Ignoring ${pluginId} role default "${permission}" for ${roleName}: the plugin does not declare it`,
{ operation: "plugin_permissions" },
);
return false;
}
// Applied before means the admin has had the chance to remove it, so
// its absence now is a decision rather than a gap.
if (appliedSet.has(`${roleName}:${permission}`)) return false;
return !coveredBy(current as string[], permission);
});
for (const permission of wanted) {
if (declared.has(permission)) {
appliedSet.add(`${roleName}:${permission}`);
}
}
if (missing.length === 0) continue;
await repository.updateRole(role.id, {
await roleRepository.updateRole(role.id, {
permissions: JSON.stringify([...(current as string[]), ...missing]),
});
const memberIds = await repository.listRoleUserIds(role.id);
for (const memberId of memberIds) {
for (const memberId of await roleRepository.listRoleUserIds(role.id)) {
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
}
} catch (error) {
@@ -332,6 +301,12 @@ async function applyRoleDefaults(
);
}
}
try {
await storage.set(pluginId, APPLIED_KEY, JSON.stringify([...appliedSet]));
} catch {
// Losing the record only means a default may be re-offered once.
}
}
function coveredBy(permissions: string[], permission: string): boolean {
@@ -345,31 +320,15 @@ function coveredBy(permissions: string[], permission: string): boolean {
return false;
}
export async function activatePlugin(
pluginId: string,
ownerUserId?: string,
): Promise<void> {
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
export async function activatePlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
if (plugin) {
try {
await registerPluginPermissions(plugin);
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_permissions" },
);
}
}
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId, ownerUserId);
const runtime = pluginBroker.get(pluginId);
if (runtime) remountRoutes(runtime);
await pluginLoader.activate(pluginId);
}
export async function deactivatePlugin(pluginId: string): Promise<void> {
@@ -392,3 +351,8 @@ export async function shutdownPlugins(): Promise<void> {
for (const plugin of loader.list()) unregisterPluginRouter(plugin.id);
await loader.shutdown();
}
/** Test seam. */
export function resetPluginRuntime(): void {
loader = null;
}
+325 -436
View File
@@ -1,64 +1,38 @@
/**
* Loads unpacked plugins and owns their worker lifecycle.
* Discovers plugins, resolves their dependency order, and owns their
* lifecycle.
*
* ## What the worker boundary actually buys you
* One tier. Every plugin, bundled or installed, is imported into the server
* process and handed a ctx built by ctx.ts. There is no worker boundary and
* no allowlist, because the worker tier had no users and the allowlist was a
* hardcoded set of twelve ids that every real plugin was already on.
*
* Be precise about this, because it is easy to overclaim.
*
* ENFORCED:
* - The plugin is handed a `ctx` object and nothing else. It gets no db
* handle, no fs module, no socket, and no credential. Everything it can do
* goes through a postMessage round trip to the broker, which checks the
* plugin's grants first and writes an audit line after.
* - Only structured-cloneable values cross the boundary, so a live object
* cannot leak by reference even by mistake.
* - The worker does not inherit process.env, so it cannot read secrets that
* were passed to the server that way. Only TMPDIR/TMP/TEMP are forwarded.
* - resourceLimits caps the worker heap, so a runaway allocation kills the
* worker instead of the server.
*
* NOT ENFORCED, and not claimed:
* - A worker_threads worker is NOT a security sandbox. It shares the process.
* A plugin that wants to can `import("node:fs")` and read whatever the
* server user can read. It can burn CPU. It can crash the process through
* native addons.
* - Node's permission model (--permission) is the only thing that would
* actually restrict builtins, and it is process-wide, not per-worker, so it
* cannot be turned on for plugin workers alone. execArgv on a Worker does
* not accept it.
*
* So the honest boundary is: plugins are code you chose to install, and this
* design guarantees they cannot QUIETLY reach data they were not granted --
* every privileged path is gated and audited. It does not guarantee
* containment. If containment is ever required, the answer is a child process
* with --permission, or a WASM/vm isolate, not worker_threads.
* What that means honestly: a plugin has the same reach as core. The
* capability gate on ctx makes privileged calls declared and auditable, not
* impossible. See ARCHITECTURE.md.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { parseManifest, type PluginManifest } from "./manifest.js";
import { parseManifest } from "./manifest.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
getBundledPluginsDir,
getPluginBackendEntry,
getPluginManifestPath,
getPluginsDir,
} from "./paths.js";
import type { PluginBootstrapData } from "./protocol.js";
import { runsInProcess } from "./first-party.js";
import {
createInProcessContext,
disposeInProcessHandle,
type InProcessHandle,
createPluginContext,
createPluginHandle,
disposePluginHandle,
type PluginHandle,
type PluginModule,
} from "./host-ctx.js";
} from "./ctx.js";
import { resolveRequirements } from "./service-registry.js";
import {
resolveSecretRequirements,
withdrawAllForPlugin,
} from "./secret-registry.js";
import { resolveSecretRequirements } from "./secret-registry.js";
export type PluginState =
| "loaded"
@@ -66,83 +40,40 @@ export type PluginState =
| "active"
| "stopping"
| "stopped"
| "crashed"
| "disabled";
| "blocked"
| "failed";
export type PluginSource = "bundled" | "user";
export const MAX_RESTART_ATTEMPTS = 3;
const RESTART_BACKOFF_MS = [1000, 5000, 15000];
const ACTIVATION_TIMEOUT_MS = 30_000;
const STABILITY_WINDOW_MS = 60_000;
const WORKER_MAX_OLD_HEAP_MB = 256;
/** Defaults for the runtime error budget. Both are configurable. */
export const DEFAULT_ERROR_THRESHOLD = 5;
export const DEFAULT_ERROR_WINDOW_MS = 60_000;
export interface LoadedPlugin {
id: string;
dir: string;
source: PluginSource;
manifest: PluginManifest;
state: PluginState;
worker: Worker | null;
/**
* Set instead of `worker` for first-party plugins that run on the main
* thread. Exactly one of the two is ever non-null.
*/
inProcess: InProcessHandle | null;
/** Consecutive crashes since the last successful activation. */
restartAttempts: number;
handle: PluginHandle | null;
lastError: string | null;
/**
* The user whose authority this plugin acts under. Set at activate time.
* Null for in-process plugins, which do not use the gated ctx and so have no
* owner to act as.
*/
ownerUserId: string | null;
/** Timestamps of recent runtime errors, trimmed to the window. */
errorTimestamps: number[];
}
export interface PluginLoaderOptions {
/**
* Called with each worker's port right after spawn so the broker can attach.
* Kept as a hook rather than a hard import so the loader stays testable on
* its own, before the broker exists.
*/
onWorkerReady?: (plugin: LoadedPlugin, worker: Worker) => void;
onWorkerGone?: (plugin: LoadedPlugin) => void;
/**
* Gives a worker plugin a chance to run its deactivate() before it is
* terminated. A hook rather than a broker import, same as the two above.
*/
onBeforeTerminate?: (plugin: LoadedPlugin) => Promise<void>;
/** Overridable so tests do not have to wait out the real backoff. */
restartBackoffMs?: number[];
/** How long a plugin must stay up before its crash counter resets. */
stabilityWindowMs?: number;
}
/**
* Resolved from this module's own location, never from process.cwd(): the build
* emits to dist/backend/backend/, and cwd is wherever the server was started
* from.
*
* Under vitest this module is the .ts source, so the sibling is
* worker-bootstrap.ts and the worker needs the tsx loader to run it. In a built
* server both are plain .js and no loader is involved.
*/
function resolveWorkerBootstrap(): { entry: string; execArgv?: string[] } {
const here = path.dirname(fileURLToPath(import.meta.url));
const compiled = path.join(here, "worker-bootstrap.js");
if (fs.existsSync(compiled)) return { entry: compiled };
const source = path.join(here, "worker-bootstrap.ts");
if (fs.existsSync(source)) {
return { entry: source, execArgv: ["--import", "tsx"] };
}
throw new Error(`Could not locate the plugin worker bootstrap near ${here}`);
errorThreshold?: number;
errorWindowMs?: number;
/** Called when a plugin trips the error budget and is torn down. */
onFailed?: (plugin: LoadedPlugin) => void;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly restartTimers = new Map<string, NodeJS.Timeout>();
private readonly stabilityTimers = new Map<string, NodeJS.Timeout>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
constructor(private readonly options: PluginLoaderOptions = {}) {}
@@ -154,10 +85,8 @@ export class PluginLoader {
return this.plugins.get(pluginId);
}
/**
* Reads and validates a plugin directory. Does not start anything.
*/
async load(dir: string): Promise<LoadedPlugin> {
/** Reads and validates one plugin directory. Starts nothing. */
async load(dir: string, source: PluginSource): Promise<LoadedPlugin> {
const manifestPath = getPluginManifestPath(dir);
let raw: unknown;
@@ -184,25 +113,22 @@ export class PluginLoader {
);
}
if (manifest.capabilities.backend) {
const entry = getPluginBackendEntry(dir);
if (!fs.existsSync(entry)) {
throw new Error(
`Plugin ${manifest.id} declares a backend but ${entry} is missing`,
);
}
const entry = getPluginBackendEntry(dir, manifest);
if (!fs.existsSync(entry)) {
throw new Error(
`Plugin ${manifest.id} backend entry ${entry} is missing`,
);
}
const plugin: LoadedPlugin = {
id: manifest.id,
dir,
source,
manifest,
state: "loaded",
worker: null,
inProcess: null,
restartAttempts: 0,
handle: null,
lastError: null,
ownerUserId: null,
errorTimestamps: [],
};
this.plugins.set(manifest.id, plugin);
@@ -213,17 +139,24 @@ export class PluginLoader {
}
/**
* Scans for plugins, loading every valid one it finds.
* Scans both roots. A user-installed plugin can never shadow a bundled one:
* the collision is rejected with an error rather than silently skipped, so
* dropping an "ssh-terminal" folder into the data directory is visibly
* refused instead of quietly ignored.
*
* Bundled first-party plugins are scanned first and a user-installed
* directory can never shadow one: dropping an "ssh-terminal" folder into the
* data directory must not replace the real transport owner.
* The check is on the parsed manifest id, not the directory name, because a
* directory can be named anything.
*/
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
const loaded: LoadedPlugin[] = [];
const seen = new Set<string>();
for (const root of [getBundledPluginsDir(), getPluginsDir()]) {
const roots: Array<{ root: string; source: PluginSource }> = [
{ root: getBundledPluginsDir(), source: "bundled" },
{ root: getPluginsDir(), source: "user" },
];
for (const { root, source } of roots) {
if (!fs.existsSync(root)) continue;
const entries = await fs.promises.readdir(root, { withFileTypes: true });
@@ -231,21 +164,20 @@ export class PluginLoader {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (seen.has(entry.name)) {
pluginLogger.warn(
`Ignoring plugin directory ${entry.name} in ${root}: a bundled plugin already uses that id`,
{ operation: "plugin_load" },
);
continue;
}
const dir = path.join(root, entry.name);
try {
const plugin = await this.load(path.join(root, entry.name));
seen.add(plugin.id);
const existing = this.plugins.get(entry.name);
if (existing && source === "user") {
throw new Error(
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
);
}
const plugin = await this.load(dir, source);
loaded.push(plugin);
} catch (error) {
pluginLogger.error(
`Skipping plugin directory ${entry.name}`,
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_load" },
);
@@ -257,83 +189,113 @@ export class PluginLoader {
}
/**
* `ownerUserId` is optional only for in-process first-party plugins, which
* do not use the gated ctx and so never act as a user. A worker plugin
* without one cannot reach any user data outside of a per-request caller
* identity (see broker.requireActor), which only exists inside an HTTP
* handler invocation and is never a substitute for having an owner at all.
* Orders plugins so a dependency always activates before its dependents.
*
* Returns the order plus the plugins that cannot run: one with a missing
* hard dependency is blocked (recoverable, the dependency may be installed
* later), one in a cycle is failed (nothing to wait for).
*/
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
resolveOrder(candidates: string[]): {
order: string[];
blocked: Map<string, string>;
cycles: Map<string, string>;
} {
const wanted = new Set(candidates);
const blocked = new Map<string, string>();
const cycles = new Map<string, string>();
const order: string[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (id: string, trail: string[]): boolean => {
if (state.get(id) === "done") return true;
if (state.get(id) === "visiting") {
const cycle = [...trail.slice(trail.indexOf(id)), id].join(" -> ");
for (const member of trail.slice(trail.indexOf(id))) {
cycles.set(member, `dependency cycle: ${cycle}`);
}
return false;
}
const plugin = this.plugins.get(id);
if (!plugin) return false;
state.set(id, "visiting");
for (const [dependencyId, range] of Object.entries(
plugin.manifest.dependencies ?? {},
)) {
const dependency = this.plugins.get(dependencyId);
if (!dependency) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not installed`,
);
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
);
state.set(id, "done");
return false;
}
if (!wanted.has(dependencyId)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which is not enabled`,
);
state.set(id, "done");
return false;
}
if (!visit(dependencyId, [...trail, id])) {
if (!cycles.has(id)) {
blocked.set(
id,
`requires plugin "${dependencyId}", which could not start`,
);
}
state.set(id, "done");
return false;
}
}
// Optional dependencies only affect ordering. A missing one is normal,
// and the plugin must keep working without it.
for (const dependencyId of Object.keys(
plugin.manifest.optionalDependencies ?? {},
)) {
if (wanted.has(dependencyId) && this.plugins.has(dependencyId)) {
visit(dependencyId, [...trail, id]);
}
}
state.set(id, "done");
if (!blocked.has(id) && !cycles.has(id)) order.push(id);
return !blocked.has(id) && !cycles.has(id);
};
for (const id of candidates) visit(id, []);
return { order, blocked, cycles };
}
async activate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
if (plugin.state === "active" || plugin.state === "activating") return;
if (!plugin.manifest.capabilities.backend) {
throw new Error(`Plugin ${pluginId} has no backend to activate`);
}
plugin.ownerUserId = ownerUserId ?? null;
await this.spawn(plugin);
}
private async spawn(plugin: LoadedPlugin): Promise<void> {
plugin.state = "activating";
plugin.lastError = null;
if (runsInProcess(plugin.id, plugin.manifest.permissions)) {
await this.spawnInProcess(plugin);
return;
}
const bootstrap: PluginBootstrapData = {
pluginId: plugin.id,
pluginDir: plugin.dir,
// file:// so the worker's dynamic import works on Windows too.
entryPath: pathToFileUrlString(getPluginBackendEntry(plugin.dir)),
manifest: plugin.manifest,
};
const { entry, execArgv } = resolveWorkerBootstrap();
const worker = new Worker(entry, {
workerData: bootstrap,
// Deliberately not process.env: the worker must not inherit secrets
// passed to the server that way. Only the temp-dir vars are forwarded,
// because tooling that writes a cache falls back to the literal string
// "undefined" without them.
env: pickTempEnv(),
resourceLimits: { maxOldGenerationSizeMb: WORKER_MAX_OLD_HEAP_MB },
stdout: true,
stderr: true,
...(execArgv ? { execArgv } : {}),
});
plugin.worker = worker;
this.options.onWorkerReady?.(plugin, worker);
worker.on("error", (error: Error) => {
plugin.lastError = error.message;
pluginLogger.error(`Plugin ${plugin.id} worker error`, error, {
operation: "plugin_worker",
});
});
worker.on("exit", (code: number) => {
this.handleExit(plugin, code);
});
await this.awaitActivation(plugin, worker);
}
/**
* Starts a first-party plugin on the main thread.
*
* There is no worker, so there is no crash isolation: an exception thrown
* later by this plugin's own callbacks lands wherever it was thrown, exactly
* as it would from any other backend module. The restart accounting below
* therefore only covers activation failure, which is the one thing we can
* still observe from here. See first-party.ts.
*/
private async spawnInProcess(plugin: LoadedPlugin): Promise<void> {
const entry = pathToFileUrlString(getPluginBackendEntry(plugin.dir));
const entry = pathToFileUrlString(
getPluginBackendEntry(plugin.dir, plugin.manifest),
);
try {
const imported = (await import(entry)) as {
@@ -365,274 +327,210 @@ export class PluginLoader {
);
}
// Secret references never block activation, even when not optional. A
// borrowed secret is resolved per call and returns null when absent, so
// a provider installed later starts working with no restart -- refusing
// to start here would turn a recoverable gap into a hard ordering
// dependency between two installs.
const secretResolution = resolveSecretRequirements(plugin.manifest);
for (const reference of secretResolution.unavailable) {
// A borrowed secret resolves per call and returns null when absent, so a
// provider installed later starts working with no restart. Refusing to
// start here would turn a recoverable gap into an install ordering rule.
for (const reference of resolveSecretRequirements(plugin.manifest)
.unavailable) {
pluginLogger.info(
`Plugin ${plugin.id} shared secret "${reference}" is not currently offered; reads will resolve to null`,
{ operation: "plugin_activate" },
);
}
const handle: InProcessHandle = {
module: { activate, deactivate },
unsubscribers: [],
providedKeys: [],
providedServices: [],
offeredSecrets: [],
};
const handle = createPluginHandle(plugin.id, { activate, deactivate });
const ctx = createPluginContext(plugin.manifest, handle);
// Attached before activate runs, not after: a plugin that registers a
// few things and then throws still has to be cleaned up, and the catch
// below can only do that if it can reach the handle.
plugin.handle = handle;
const ctx = createInProcessContext(plugin.manifest, handle);
await withTimeout(
Promise.resolve(activate(ctx)),
ACTIVATION_TIMEOUT_MS,
`Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`,
);
plugin.inProcess = handle;
plugin.state = "active";
this.scheduleStabilityReset(plugin);
pluginLogger.success(`Activated plugin ${plugin.id} (in-process)`, {
plugin.errorTimestamps = [];
if (!this.activationOrder.includes(plugin.id)) {
this.activationOrder.push(plugin.id);
}
pluginLogger.success(`Activated plugin ${plugin.id}`, {
operation: "plugin_activate",
});
} catch (error) {
plugin.lastError = error instanceof Error ? error.message : String(error);
plugin.inProcess = null;
plugin.state = "crashed";
plugin.state = "failed";
// Activation may have registered things before it threw. Dispose them
// rather than leaving a half-started plugin holding resources, but do
// not call the plugin's own deactivate: it never finished starting.
if (plugin.handle) {
await disposePluginHandle(plugin.handle, plugin.id, {
runDeactivate: false,
});
plugin.handle = null;
}
throw error instanceof Error ? error : new Error(plugin.lastError);
}
}
private awaitActivation(plugin: LoadedPlugin, worker: Worker): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
void this.terminate(plugin);
reject(
new Error(
`Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`,
),
);
}, ACTIVATION_TIMEOUT_MS);
/** Activates a set of plugins in dependency order. */
async activateAll(pluginIds: string[]): Promise<{
activated: string[];
blocked: Map<string, string>;
failed: Map<string, string>;
}> {
const { order, blocked, cycles } = this.resolveOrder(pluginIds);
const activated: string[] = [];
const failed = new Map<string, string>(cycles);
const onMessage = (message: unknown) => {
const m = message as {
id?: number;
ok?: boolean;
error?: { message: string };
};
// id 0 is reserved for the bootstrap's activation result.
if (!m || m.id !== 0) return;
cleanup();
if (m.ok) {
plugin.state = "active";
this.scheduleStabilityReset(plugin);
pluginLogger.success(`Activated plugin ${plugin.id}`, {
operation: "plugin_activate",
});
resolve();
} else {
plugin.lastError = m.error?.message ?? "activation failed";
void this.terminate(plugin);
reject(new Error(plugin.lastError));
}
};
const onExit = () => {
cleanup();
reject(
new Error(
`Plugin ${plugin.id} worker exited before activating${
plugin.lastError ? `: ${plugin.lastError}` : ""
}`,
),
);
};
function cleanup() {
clearTimeout(timer);
worker.off("message", onMessage);
worker.off("exit", onExit);
for (const [id, reason] of blocked) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "blocked";
plugin.lastError = reason;
}
worker.on("message", onMessage);
worker.on("exit", onExit);
});
}
/**
* Clears the crash counter only once a plugin has stayed up for a while.
* Resetting it on activation alone would let a plugin that activates cleanly
* and then dies restart forever, because each attempt would look like the
* first one.
*/
private scheduleStabilityReset(plugin: LoadedPlugin): void {
const existing = this.stabilityTimers.get(plugin.id);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.stabilityTimers.delete(plugin.id);
if (plugin.state === "active") plugin.restartAttempts = 0;
}, this.options.stabilityWindowMs ?? STABILITY_WINDOW_MS);
timer.unref?.();
this.stabilityTimers.set(plugin.id, timer);
}
private handleExit(plugin: LoadedPlugin, code: number): void {
const stabilityTimer = this.stabilityTimers.get(plugin.id);
if (stabilityTimer) {
clearTimeout(stabilityTimer);
this.stabilityTimers.delete(plugin.id);
}
plugin.worker = null;
this.options.onWorkerGone?.(plugin);
// A deliberate stop is not a crash.
if (plugin.state === "stopping" || plugin.state === "stopped") {
plugin.state = "stopped";
return;
}
if (plugin.state === "disabled") return;
plugin.state = "crashed";
plugin.restartAttempts += 1;
if (plugin.restartAttempts > MAX_RESTART_ATTEMPTS) {
plugin.state = "disabled";
pluginLogger.error(
`Plugin ${plugin.id} crashed ${MAX_RESTART_ATTEMPTS} times and has been disabled`,
new Error(plugin.lastError ?? `worker exited with code ${code}`),
{ operation: "plugin_crash" },
);
return;
}
const backoff = this.options.restartBackoffMs ?? RESTART_BACKOFF_MS;
const delay =
backoff[plugin.restartAttempts - 1] ?? backoff[backoff.length - 1];
pluginLogger.warn(
`Plugin ${plugin.id} crashed, restarting in ${delay}ms (attempt ${plugin.restartAttempts}/${MAX_RESTART_ATTEMPTS})`,
{ operation: "plugin_crash" },
);
const timer = setTimeout(() => {
this.restartTimers.delete(plugin.id);
if (plugin.state !== "crashed") return;
this.spawn(plugin).catch((error) => {
plugin.lastError =
error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Plugin ${id} is blocked: ${reason}`, {
operation: "plugin_activate",
});
}, delay);
this.restartTimers.set(plugin.id, timer);
}
/**
* Clears the crash counter and starts the plugin again. This is what the
* Retry button calls once a plugin has been auto-disabled.
*/
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
plugin.restartAttempts = 0;
plugin.state = "loaded";
plugin.lastError = null;
// In-process plugins legitimately have no owner, so only worker plugins
// are held to this.
if (
!plugin.ownerUserId &&
!runsInProcess(plugin.id, plugin.manifest.permissions)
) {
throw new Error(
`Plugin ${pluginId} has never been activated, so there is nothing to retry`,
);
}
await this.spawn(plugin);
for (const [id, reason] of cycles) {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.state = "failed";
plugin.lastError = reason;
}
pluginLogger.error(`Plugin ${id} cannot start`, new Error(reason), {
operation: "plugin_activate",
});
}
for (const id of order) {
try {
await this.activate(id);
activated.push(id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failed.set(id, message);
pluginLogger.error(
`Failed to activate plugin ${id}`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_activate" },
);
}
}
return { activated, blocked, failed };
}
async deactivate(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
const timer = this.restartTimers.get(pluginId);
if (timer) {
clearTimeout(timer);
this.restartTimers.delete(pluginId);
}
const stabilityTimer = this.stabilityTimers.get(pluginId);
if (stabilityTimer) {
clearTimeout(stabilityTimer);
this.stabilityTimers.delete(pluginId);
}
this.activationOrder = this.activationOrder.filter((id) => id !== pluginId);
if (!plugin.worker && !plugin.inProcess) {
if (!plugin.handle) {
plugin.state = "stopped";
return;
}
plugin.state = "stopping";
await this.terminate(plugin);
const handle = plugin.handle;
plugin.handle = null;
// Disposal never throws, so a plugin whose deactivate() misbehaves still
// ends up stopped rather than stuck in "stopping" forever.
await disposePluginHandle(handle, plugin.id);
plugin.state = "stopped";
}
private async terminate(plugin: LoadedPlugin): Promise<void> {
// An in-process plugin owns real resources -- a listening port, live SSH
// sessions -- and there is no thread to kill, so its own cleanup is the
// only thing that releases them.
if (plugin.inProcess) {
const handle = plugin.inProcess;
plugin.inProcess = null;
this.options.onWorkerGone?.(plugin);
await disposeInProcessHandle(handle, plugin.id);
return;
}
/**
* Records a runtime error against a plugin. Past the threshold inside the
* window the plugin is marked failed and torn down, because a plugin
* throwing on every call is worse than one that is off.
*/
async reportError(pluginId: string, error: unknown): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) return;
// A worker plugin cannot offer a shared secret today, but terminate() is
// the one path every tier goes through, so the sweep belongs here rather
// than only in the in-process branch above.
withdrawAllForPlugin(plugin.id);
const message = error instanceof Error ? error.message : String(error);
plugin.lastError = message;
const worker = plugin.worker;
if (!worker) return;
pluginLogger.error(
`Plugin ${pluginId} threw at runtime`,
error instanceof Error ? error : new Error(message),
{ operation: "plugin_runtime" },
);
// Only on a deliberate stop. A crashed worker has nothing left to ask.
if (plugin.state === "stopping" && this.options.onBeforeTerminate) {
try {
await this.options.onBeforeTerminate(plugin);
} catch {
// Cleanup is best-effort; the worker is going away regardless.
}
}
const windowMs = this.options.errorWindowMs ?? DEFAULT_ERROR_WINDOW_MS;
const threshold = this.options.errorThreshold ?? DEFAULT_ERROR_THRESHOLD;
const now = Date.now();
plugin.worker = null;
try {
await worker.terminate();
} catch {
// Already gone.
}
plugin.errorTimestamps = [
...plugin.errorTimestamps.filter((at) => now - at < windowMs),
now,
];
if (plugin.errorTimestamps.length < threshold) return;
pluginLogger.error(
`Plugin ${pluginId} failed ${plugin.errorTimestamps.length} times in ${windowMs}ms and has been stopped`,
new Error(message),
{ operation: "plugin_runtime" },
);
await this.deactivate(pluginId);
plugin.state = "failed";
plugin.lastError = message;
plugin.errorTimestamps = [];
this.options.onFailed?.(plugin);
}
/** Stops everything. Called on shutdown and by tests. */
async shutdown(): Promise<void> {
for (const timer of this.restartTimers.values()) clearTimeout(timer);
this.restartTimers.clear();
for (const timer of this.stabilityTimers.values()) clearTimeout(timer);
this.stabilityTimers.clear();
/**
* Wraps a plugin callback so a throw is counted rather than escaping into
* whichever core call site invoked it.
*/
wrap<Args extends unknown[], Result>(
pluginId: string,
fn: (...args: Args) => Result | Promise<Result>,
): (...args: Args) => Promise<Result | undefined> {
return async (...args: Args) => {
try {
return await fn(...args);
} catch (error) {
await this.reportError(pluginId, error);
return undefined;
}
};
}
await Promise.all(
[...this.plugins.values()].map(async (plugin) => {
plugin.state = "stopping";
await this.terminate(plugin);
plugin.state = "stopped";
}),
);
/** Clears the error budget and starts the plugin again. */
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
plugin.errorTimestamps = [];
plugin.lastError = null;
plugin.state = "loaded";
await this.activate(pluginId);
}
/** Stops everything, reverse activation order. */
async shutdown(): Promise<void> {
for (const id of [...this.activationOrder].reverse()) {
try {
await this.deactivate(id);
} catch (error) {
pluginLogger.error(
`Failed to deactivate plugin ${id} during shutdown`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_shutdown" },
);
}
}
this.activationOrder = [];
}
private requirePlugin(pluginId: string): LoadedPlugin {
@@ -642,15 +540,6 @@ export class PluginLoader {
}
}
function pickTempEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const key of ["TMPDIR", "TMP", "TEMP"]) {
const value = process.env[key];
if (value) env[key] = value;
}
return env;
}
function withTimeout<T>(
promise: Promise<T>,
ms: number,
+47 -844
View File
@@ -1,848 +1,51 @@
/**
* Runtime plugin manifest validation.
* Manifest validation, re-exported from the SDK.
*
* This is a TypeScript port of scripts/validate-plugin-manifest.cjs. The two
* are deliberately separate: the .cjs one is a build/authoring tool run from a
* checkout, this one runs inside the server, where scripts/ does not exist
* (the Docker image only ships dist/). Both enforce the same contract, which
* is written down in scripts/plugin-manifest.schema.json.
*
* The enums below are duplicated from that schema rather than read from it at
* runtime, for the same reason. If you change the schema, change both.
* The contract lives in @termix/plugin-sdk/manifest so the server, the
* authoring script and plugin authors all read the same rules. This file used
* to carry its own copy of the enums and validators, which meant three places
* had to be edited together and quietly drifted when they were not.
*/
import semver from "semver";
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
export const PLUGIN_PERMISSIONS = [
"hosts.read",
"hosts.write",
"credentials.use",
"ssh.exec",
"ssh.sftp",
"storage.own",
"storage.secrets",
"network.outbound",
"events.read",
"notify.send",
"users.read",
"process.sidecar",
// Reserved for first-party plugins. parseManifest refuses it for any id not
// on the hardcoded allowlist in first-party.ts, so declaring it does not
// grant it.
"process:transport-owner",
"ui.tab",
"ui.rail",
"ui.card",
"ui.settings",
"ui.palette",
] as const;
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity",
] as const;
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
/**
* Contribution kinds a UI action slot can accept. Closed on purpose: a
* free-form list would validate nothing. Adding a kind is a one-line edit
* here and in the two other validators.
*/
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const REQUIRED_TOP_LEVEL = [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
"permissions",
"sidecars",
];
const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
/**
* A shared secret key is a flat slug, e.g. "api-key". Not dotted: the plugin
* id is already a separate field, so a dotted key would only invite encoding
* the owner twice and disagreeing with itself.
*/
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/**
* Action and slot ids are dotted like service names, but each segment may be
* camelCase: these name a frontend function ("ai.openWithContext"), not a
* lowercase service contract, so SERVICE_PATTERN is too strict to reuse.
*/
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
/** A handler names a JS export, so it follows identifier rules, not dotted ones. */
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/**
* The plugin SDK major version this build implements. A manifest asking for a
* different engine.api is refused rather than loaded and hoped for -- this is
* the real compatibility gate (engine.termix is informational only).
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export interface PluginManifest {
id: string;
name: string;
version: string;
description: string;
author: { name: string; url?: string };
license: string;
repository?: string;
category: string;
icon?: string;
engine: { termix: string; api: string };
capabilities: {
backend: boolean;
frontend: boolean;
electron: boolean;
platforms: string[];
};
permissions: string[];
contributes?: {
tabs?: Array<{
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}>;
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
permissionGroup?: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
};
settingsPanel?: { titleKey: string };
dashboardCards?: Array<{ id: string; titleKey: string }>;
apiPrefix?: string;
/** Named frontend actions this plugin exposes for other plugins to invoke. */
actions?: PluginActionContribution[];
/** Places this plugin offers other plugins to contribute a UI element. */
actionSlots?: PluginActionSlot[];
};
dependencies?: { plugins?: Record<string, string> };
/**
* Named services this plugin exposes to other plugins. Each one is gated by
* its own RBAC permission, which the plugin's own permissionGroup must
* declare -- see parseManifest.
*/
provides?: PluginServiceProvide[];
/** Named services this plugin needs another active plugin to provide. */
requires?: PluginServiceRequire[];
/**
* Secrets this plugin offers other plugins by reference. Gated by one of
* this plugin's own permissionGroup permissions -- see parseManifest.
*/
providesSecret?: PluginSecretProvide[];
/** Secrets this plugin wants to borrow from another plugin, by reference. */
requiresSecret?: PluginSecretRequire[];
sidecars: Array<{ id: string; binary: string }>;
}
/**
* A host-level checkbox this plugin contributes to the host editor (e.g.
* "Enable RDP"), backed by an existing boolean column on the host record.
* Most plugins declare one; a plugin covering several independent protocols
* on the same host (rdp/vnc/telnet) declares several -- see
* contributes.hostCapability, which accepts either shape.
*/
export interface HostCapabilityContribution {
key: string;
labelKey: string;
editorTab: string;
}
export interface PluginServiceProvide {
service: string;
version: string;
/** The role permission a calling user needs to reach this service. */
permission: string;
}
export interface PluginServiceRequire {
service: string;
versionRange: string;
/** When true, an unsatisfied requirement is skipped instead of failing activation. */
optional?: boolean;
}
export interface PluginSecretProvide {
/** The secret's name within this plugin, e.g. "api-key". */
key: string;
/** The role permission a borrowing user needs. */
permission: string;
/** Shown in the admin UI so the sharing is legible without reading code. */
descriptionKey?: string;
}
export interface PluginSecretRequire {
/** The plugin id that offers the secret. */
plugin: string;
/** The secret's name within that plugin. */
key: string;
/**
* When true, the provider being absent is normal and activation proceeds.
* A non-optional reference still activates -- the read just returns null --
* but resolveSecretRequirements reports it as an error for install feedback.
*/
optional?: boolean;
}
/**
* A named frontend action, declared here and bound at runtime.
*
* The split matters: this manifest entry is inert JSON the server can read and
* audit without running any plugin code, while the function itself is supplied
* in the browser by registerAction(id, fn). `handler` records which frontend
* export is meant to end up under `id`, so the contract is readable from the
* manifest alone and a future frontend loader can wire module[handler]
* automatically. Today nothing reads `handler` at runtime.
*/
export interface PluginActionContribution {
/** Dotted and globally unique, e.g. "ai.openWithContext". */
id: string;
titleKey: string;
/** Name of the frontend export implementing this action. */
handler: string;
/** Lucide icon name, same convention as contributes.tabs[].icon. */
icon?: string;
/**
* Role permission the acting user needs before the action is offered.
* Optional: an ungated action renders for everyone. When set, parseManifest
* requires it to be declared in this plugin's own permissionGroup.
*/
permission?: string;
/** Slot this action is intended for. Advisory; binding happens at runtime. */
slot?: string;
kind?: ActionContributionKind;
}
/**
* A place this plugin offers other plugins to contribute a UI element.
*
* A contribution's `kind` is checked against `accepts` in the frontend at
* registration time, not here: the slot is usually declared by a different
* plugin, and a manifest is validated on its own.
*/
export interface PluginActionSlot {
/** Dotted, e.g. "terminal.toolbar". */
id: string;
accepts: ActionContributionKind[];
descriptionKey?: string;
}
export function validateManifest(manifest: unknown): string[] {
const errors: string[] = [];
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
return ["Manifest must be a JSON object"];
}
const m = manifest as Record<string, unknown>;
for (const field of REQUIRED_TOP_LEVEL) {
if (!(field in m)) {
errors.push(`Missing required field: "${field}"`);
}
}
if (typeof m.id === "string" && !ID_PATTERN.test(m.id)) {
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${m.id}"`);
}
if (typeof m.version === "string" && !SEMVER_PATTERN.test(m.version)) {
errors.push(`Field "version" must be valid semver, got: "${m.version}"`);
}
if (m.author && typeof m.author === "object") {
if (!(m.author as Record<string, unknown>).name) {
errors.push('Field "author.name" is required');
}
}
if (typeof m.category === "string") {
if (!PLUGIN_CATEGORIES.includes(m.category as never)) {
errors.push(
`Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${m.category}"`,
);
}
} else if ("category" in m) {
errors.push('Field "category" must be a string');
}
if ("icon" in m && typeof m.icon !== "string") {
errors.push('Field "icon" must be a string');
}
if (m.engine && typeof m.engine === "object") {
const engine = m.engine as Record<string, unknown>;
if (!engine.termix) {
errors.push('Field "engine.termix" is required');
}
if (!API_VERSION_PATTERN.test(String(engine.api))) {
errors.push(
`Field "engine.api" must be an integer-as-string, got: "${engine.api}"`,
);
}
}
if (m.capabilities && typeof m.capabilities === "object") {
const caps = m.capabilities as Record<string, unknown>;
for (const field of ["backend", "frontend", "electron"]) {
if (typeof caps[field] !== "boolean") {
errors.push(`Field "capabilities.${field}" must be a boolean`);
}
}
if (!Array.isArray(caps.platforms)) {
errors.push('Field "capabilities.platforms" must be an array');
}
}
if (Array.isArray(m.permissions)) {
for (const permission of m.permissions) {
if (!PLUGIN_PERMISSIONS.includes(permission as never)) {
errors.push(
`Unknown permission: "${permission}". Known values: ${PLUGIN_PERMISSIONS.join(", ")}`,
);
}
}
} else if ("permissions" in m) {
errors.push('Field "permissions" must be an array');
}
if (Array.isArray(m.sidecars)) {
m.sidecars.forEach((sidecar: unknown, index: number) => {
if (!sidecar || typeof sidecar !== "object") {
errors.push(`sidecars[${index}] must be an object`);
return;
}
const s = sidecar as Record<string, unknown>;
if (!s.id) errors.push(`sidecars[${index}].id is required`);
if (!s.binary) errors.push(`sidecars[${index}].binary is required`);
});
} else if ("sidecars" in m) {
errors.push('Field "sidecars" must be an array');
}
errors.push(...validateProvides(m.provides));
errors.push(...validateRequires(m.requires));
errors.push(...validateProvidesSecret(m.providesSecret));
errors.push(...validateRequiresSecret(m.requiresSecret));
if (m.contributes && typeof m.contributes === "object") {
errors.push(
...validateContributes(m.contributes as Record<string, unknown>),
);
}
return errors;
}
function validateProvides(provides: unknown): string[] {
if (provides === undefined) return [];
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
const errors: string[] = [];
const seen = new Set<string>();
provides.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`provides[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
errors.push(
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
);
} else if (seen.has(e.service)) {
errors.push(`provides[${index}].service is a duplicate: "${e.service}"`);
} else {
seen.add(e.service);
}
if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) {
errors.push(
`provides[${index}].version must be valid semver, got: "${String(e.version)}"`,
);
}
if (typeof e.permission !== "string" || e.permission.length === 0) {
errors.push(`provides[${index}].permission is required`);
}
});
return errors;
}
function validateRequires(requires: unknown): string[] {
if (requires === undefined) return [];
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
const errors: string[] = [];
const seen = new Set<string>();
requires.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requires[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
errors.push(
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
);
} else if (seen.has(e.service)) {
errors.push(`requires[${index}].service is a duplicate: "${e.service}"`);
} else {
seen.add(e.service);
}
if (
typeof e.versionRange !== "string" ||
semver.validRange(e.versionRange) === null
) {
errors.push(
`requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`,
);
}
if ("optional" in e && typeof e.optional !== "boolean") {
errors.push(`requires[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateProvidesSecret(providesSecret: unknown): string[] {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
providesSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
} else if (seen.has(e.key)) {
errors.push(`providesSecret[${index}].key is a duplicate: "${e.key}"`);
} else {
seen.add(e.key);
}
if (typeof e.permission !== "string" || e.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in e &&
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret: unknown): string[] {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
requiresSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.plugin !== "string" || !ID_PATTERN.test(e.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${String(e.plugin)}"`,
);
}
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
}
if (typeof e.plugin === "string" && typeof e.key === "string") {
const id = `${e.plugin}:${e.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in e && typeof e.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes: Record<string, unknown>): string[] {
const errors: string[] = [];
if ("tabs" in contributes) {
if (!Array.isArray(contributes.tabs)) {
errors.push('Field "contributes.tabs" must be an array');
} else {
contributes.tabs.forEach((tab: unknown, index: number) => {
const t = (tab ?? {}) as Record<string, unknown>;
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
if (!(field in t)) {
errors.push(`contributes.tabs[${index}].${field} is required`);
}
}
if (Array.isArray(t.openFrom)) {
for (const value of t.openFrom) {
if (!OPEN_FROM_VALUES.includes(value as string)) {
errors.push(
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
);
}
}
}
});
}
}
if ("hostCapability" in contributes) {
const raw = contributes.hostCapability;
const entries = Array.isArray(raw) ? raw : [raw];
const seenKeys = new Set<string>();
entries.forEach((entry, index) => {
const hc = (entry ?? {}) as Record<string, unknown>;
const prefix = Array.isArray(raw)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
for (const field of ["key", "labelKey", "editorTab"]) {
if (!(field in hc)) {
errors.push(`${prefix}.${field} is required`);
}
}
if (typeof hc.key === "string") {
if (seenKeys.has(hc.key)) {
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
}
seenKeys.add(hc.key);
}
});
}
if ("permissionGroup" in contributes) {
const group = (contributes.permissionGroup ?? {}) as Record<
string,
unknown
>;
if (!group.group) {
errors.push("contributes.permissionGroup.group is required");
}
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(
"contributes.permissionGroup.permissions must be a non-empty array",
);
}
}
if ("settingsPanel" in contributes) {
const panel = (contributes.settingsPanel ?? {}) as Record<string, unknown>;
if (!panel.titleKey) {
errors.push("contributes.settingsPanel.titleKey is required");
}
}
if ("dashboardCards" in contributes) {
if (!Array.isArray(contributes.dashboardCards)) {
errors.push('Field "contributes.dashboardCards" must be an array');
} else {
contributes.dashboardCards.forEach((card: unknown, index: number) => {
const c = (card ?? {}) as Record<string, unknown>;
for (const field of ["id", "titleKey"]) {
if (!(field in c)) {
errors.push(
`contributes.dashboardCards[${index}].${field} is required`,
);
}
}
});
}
}
if ("apiPrefix" in contributes) {
if (
typeof contributes.apiPrefix !== "string" ||
!API_PREFIX_PATTERN.test(contributes.apiPrefix)
) {
errors.push(
`Field "contributes.apiPrefix" must match ${API_PREFIX_PATTERN}`,
);
}
}
if ("actions" in contributes) {
errors.push(...validateActions(contributes.actions));
}
if ("actionSlots" in contributes) {
errors.push(...validateActionSlots(contributes.actionSlots));
}
return errors;
}
function validateActions(actions: unknown): string[] {
if (!Array.isArray(actions)) {
return ['Field "contributes.actions" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
actions.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actions[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
errors.push(
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
);
} else if (seen.has(e.id)) {
errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`);
} else {
seen.add(e.id);
}
if (typeof e.titleKey !== "string" || e.titleKey.length === 0) {
errors.push(`contributes.actions[${index}].titleKey is required`);
}
if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) {
errors.push(
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`,
);
}
if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) {
errors.push(`contributes.actions[${index}].icon must be a string`);
}
if (
"permission" in e &&
(typeof e.permission !== "string" || e.permission.length === 0)
) {
errors.push(`contributes.actions[${index}].permission must be a string`);
}
if (
"slot" in e &&
(typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot))
) {
errors.push(
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`,
);
}
if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) {
errors.push(
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`,
);
}
});
return errors;
}
function validateActionSlots(slots: unknown): string[] {
if (!Array.isArray(slots)) {
return ['Field "contributes.actionSlots" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
slots.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actionSlots[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
errors.push(
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
);
} else if (seen.has(e.id)) {
errors.push(
`contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`,
);
} else {
seen.add(e.id);
}
if (!Array.isArray(e.accepts) || e.accepts.length === 0) {
errors.push(
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
);
} else {
for (const kind of e.accepts) {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
errors.push(
`contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
}
}
if (
"descriptionKey" in e &&
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
) {
errors.push(
`contributes.actionSlots[${index}].descriptionKey must be a string`,
);
}
});
return errors;
}
/**
* Validates and narrows in one step. Unlike validateManifest, this also
* enforces the SDK compatibility gate, which only the runtime cares about --
* the authoring-time validator has no single Termix build to check against.
*/
export function parseManifest(raw: unknown): {
manifest?: PluginManifest;
errors: string[];
} {
const errors = validateManifest(raw);
if (errors.length > 0) return { errors };
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
return {
errors: [
`Plugin targets SDK api version "${manifest.engine.api}", this Termix build implements "${SUPPORTED_PLUGIN_API_VERSION}"`,
],
};
}
// A service permission has to be one the plugin's own permissionGroup
// declares, or it would never reach the catalog and every call through it
// would deny against a permission no admin can grant.
const declaredPermissions =
manifest.contributes?.permissionGroup?.permissions ?? [];
for (const entry of manifest.provides ?? []) {
if (!declaredPermissions.includes(entry.permission)) {
return {
errors: [
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// Same rule for a shared secret. This is what keeps sharing on the existing
// RBAC grant path instead of becoming a separate mechanism: the permission
// gating a borrowed secret is an ordinary role permission in the provider's
// own group, editable per role or per user like any other.
for (const entry of manifest.providesSecret ?? []) {
if (!declaredPermissions.includes(entry.permission)) {
return {
errors: [
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// A plugin borrowing from itself would bypass nothing, but it is always a
// mistake: its own secrets are reachable through ctx.secrets directly.
for (const entry of manifest.requiresSecret ?? []) {
if (entry.plugin === manifest.id) {
return {
errors: [
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
],
};
}
}
// Same rule for UI actions, for the same reason: a permission the catalog
// never sees is one no admin can grant, so the action would be invisible
// forever rather than merely denied.
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declaredPermissions.includes(action.permission)) {
return {
errors: [
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// Refused outright rather than ignored: a plugin that asked to run in-process
// and was quietly downgraded to a worker would fail later in a confusing way,
// and the manifest would still claim reach it does not have.
if (
manifest.permissions.includes(TRANSPORT_OWNER_CAPABILITY) &&
!isFirstParty(manifest.id)
) {
return {
errors: [
`Permission "${TRANSPORT_OWNER_CAPABILITY}" is reserved for first-party plugins and cannot be declared by "${manifest.id}"`,
],
};
}
return { manifest, errors: [] };
}
export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_CATEGORIES,
PLUGIN_PLATFORMS,
ACTION_CONTRIBUTION_KINDS,
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
} from "@termix/plugin-sdk/manifest";
export type {
PluginManifest,
PluginAuthor,
PluginEngine,
PluginContributions,
PluginTabContribution,
PluginActionContribution,
PluginActionSlot,
PluginPermissionGroup,
PluginServiceProvide,
PluginServiceRequire,
PluginSecretProvide,
PluginSecretRequire,
HostCapabilityContribution,
ActionContributionKind,
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
export {
CAPABILITY_CATALOG,
CAPABILITY_IDS,
isKnownCapability,
getCapabilityInfo,
} from "@termix/plugin-sdk/capabilities";
export type {
Capability,
CapabilityInfo,
CapabilityRisk,
} from "@termix/plugin-sdk/capabilities";
+29 -11
View File
@@ -1,26 +1,30 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEFAULT_BACKEND_ENTRY,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
/**
* Where unpacked plugins live. Resolved per call rather than captured at module
* scope: tests set DATA_DIR in beforeEach, and a module-scope constant would
* freeze whatever the first import saw.
* Where user-installed plugins live. Resolved per call rather than captured at
* module scope: tests set DATA_DIR in beforeEach, and a module-scope constant
* would freeze whatever the first import saw.
*/
export function getPluginsDir(): string {
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
}
/**
* Where first-party plugins that ship with Termix live.
* Where the plugins that ship with Termix live.
*
* Separate from getPluginsDir() because that one is user data: a bundled
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, same reasoning as the
* worker bootstrap: the build emits to dist/backend/backend/plugins/, and the
* bundled plugins sit beside dist/backend.
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
@@ -30,9 +34,10 @@ export function getBundledPluginsDir(): string {
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
path.resolve(here, "../../../plugins"),
// Running from source under vitest/tsx: src/backend/plugins -> plugins
path.resolve(here, "../../../plugins"),
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
@@ -46,8 +51,21 @@ export function getPluginDir(pluginId: string): string {
return path.join(getPluginsDir(), pluginId);
}
export function getPluginBackendEntry(pluginDir: string): string {
return path.join(pluginDir, "backend", "index.mjs");
/**
* The backend entry, from the manifest's own `backend` field.
*
* Bundled plugins still ship a hand-written backend/index.mjs, so that stays
* the fallback until A2 gives every plugin a real build.
*/
export function getPluginBackendEntry(
pluginDir: string,
manifest?: Pick<PluginManifest, "backend">,
): string {
const legacy = path.join(pluginDir, "backend", "index.mjs");
if (!manifest?.backend || manifest.backend === DEFAULT_BACKEND_ENTRY) {
if (fs.existsSync(legacy)) return legacy;
}
return path.join(pluginDir, manifest?.backend ?? DEFAULT_BACKEND_ENTRY);
}
export function getPluginManifestPath(pluginDir: string): string {
+17 -34
View File
@@ -1,40 +1,23 @@
/**
* The grant check every privileged ctx call goes through.
* The grant check every guarded ctx call goes through.
*
* Capability strings are the manifest's own permission enum, stored verbatim in
* plugin_permission_grants.capability -- no translation layer, so the manifest
* stays the single source of truth for what is grantable.
* Two things have to be true for a capability to count as granted: the
* manifest declares it (the plugin asked for it up front) and it is granted in
* plugin_permission_grants (someone, or the bundling process, said yes). A
* grant for something the manifest never declared is ignored rather than
* honoured, so widening a plugin's reach always requires a new manifest the
* user can see.
*
* Two separate things have to be true for a capability to count as granted:
* the manifest must declare it (the plugin asked for it up front) and an admin
* must have granted it (someone said yes). A grant for something the manifest
* never declared is ignored rather than honoured, so widening a plugin's reach
* always requires a new manifest the user can see.
* The cache is invalidated explicitly on every grant change rather than by
* TTL: a revoke that takes effect a minute later is not a revoke.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import type { PluginPermission } from "./manifest.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
const CACHE_TTL_MS = 60_000;
export { PluginCapabilityError };
interface CacheEntry {
capabilities: Set<string>;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
export class PluginPermissionError extends Error {
readonly code = "EPLUGINPERM";
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's permissions array and grant it in the plugin's settings.`,
);
this.name = "PluginPermissionError";
}
}
const cache = new Map<string, Set<string>>();
export function invalidatePluginPermissionCache(pluginId?: string): void {
if (pluginId) cache.delete(pluginId);
@@ -43,19 +26,19 @@ export function invalidatePluginPermissionCache(pluginId?: string): void {
async function loadGrants(pluginId: string): Promise<Set<string>> {
const cached = cache.get(pluginId);
if (cached && cached.expiresAt > Date.now()) return cached.capabilities;
if (cached) return cached;
const rows =
await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId);
const capabilities = new Set(rows.map((row) => row.capability));
cache.set(pluginId, { capabilities, expiresAt: Date.now() + CACHE_TTL_MS });
cache.set(pluginId, capabilities);
return capabilities;
}
export async function hasCapability(
pluginId: string,
capability: PluginPermission | string,
capability: string,
declared: readonly string[],
): Promise<boolean> {
if (!declared.includes(capability)) return false;
@@ -64,10 +47,10 @@ export async function hasCapability(
export async function assertCapability(
pluginId: string,
capability: PluginPermission | string,
capability: string,
declared: readonly string[],
): Promise<void> {
if (!(await hasCapability(pluginId, capability, declared))) {
throw new PluginPermissionError(pluginId, capability);
throw new PluginCapabilityError(pluginId, capability);
}
}
-73
View File
@@ -1,73 +0,0 @@
/**
* Wire format between a plugin worker and the main-thread broker.
*
* Everything crossing this boundary must be structured-cloneable. That is not
* just a serialisation detail: it is the reason a plugin cannot be handed a db
* handle, an ssh2 Client or a live socket by accident. Live objects stay in the
* main thread and the worker refers to them by opaque handle strings.
*/
export interface PluginRequest {
/** Correlates a reply to its request. Worker-generated, monotonic. */
id: number;
/** Dotted ctx path, e.g. "hosts.get" or "storage.set". */
method: string;
args: unknown[];
/**
* The user whose HTTP request is currently executing inside the worker, if
* any. Set by the worker's own call() from its AsyncLocalStorage context,
* never by plugin code -- no ctx method accepts a userId argument, so a
* plugin has no way to set or forge this itself. Absent for a ctx call made
* from a timer or event listener, which has no inbound request to derive an
* actor from; the broker falls back to the plugin's ownerUserId then.
*/
callerUserId?: string;
}
export interface PluginResponseOk {
id: number;
ok: true;
value: unknown;
}
export interface PluginResponseErr {
id: number;
ok: false;
error: { message: string; code?: string };
}
export type PluginResponse = PluginResponseOk | PluginResponseErr;
/** Main thread -> worker, pushed rather than requested. */
export interface PluginPush {
kind: "event" | "http" | "timer" | "deactivate";
/** Event topic, route key, or timer id depending on kind. */
key: string;
payload: unknown;
/** Set when the push expects a reply (an HTTP route invocation). */
replyTo?: number;
}
/** First message the worker receives, before any plugin code runs. */
export interface PluginBootstrapData {
pluginId: string;
pluginDir: string;
entryPath: string;
manifest: unknown;
}
export function isPluginRequest(value: unknown): value is PluginRequest {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "number" &&
typeof v.method === "string" &&
Array.isArray(v.args)
);
}
export function isPluginResponse(value: unknown): value is PluginResponse {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.ok === "boolean";
}
-301
View File
@@ -1,301 +0,0 @@
/**
* The worker thread's entry point. Runs before any plugin code.
*
* Builds the ctx object, imports the plugin's backend/index.mjs, and calls its
* exported activate(ctx). The plugin gets ctx and nothing else from us -- no
* db, no fs, no net handle is ever passed in. See loader.ts for an honest
* account of what that does and does not guarantee.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { parentPort, workerData } from "node:worker_threads";
import type {
PluginBootstrapData,
PluginPush,
PluginResponse,
} from "./protocol.js";
import { isPluginResponse } from "./protocol.js";
const port = parentPort;
if (!port) {
throw new Error("Plugin worker started without a parent port");
}
const boot = workerData as PluginBootstrapData;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>();
/** Handlers the plugin registered, keyed the same way the broker pushes them. */
const httpRoutes = new Map<string, (req: unknown) => unknown>();
const eventListeners = new Map<string, Set<(payload: unknown) => void>>();
const timers = new Map<string, () => void>();
/**
* Tracks which user's HTTP request is currently executing, so a ctx call made
* anywhere in that request's call stack -- including deep inside plugin code
* that has no idea this exists -- can be attributed to them without adding a
* userId parameter to every ctx method.
*
* Only entered around an ctx.http.route handler invocation (see handlePush
* below). A ctx call made from a schedule.every timer or an events.on
* listener runs outside any run() call, so getStore() correctly returns
* undefined for those and the broker falls back to the plugin's owner.
*/
const requestActor = new AsyncLocalStorage<{ userId: string }>();
function call(method: string, ...args: unknown[]): Promise<unknown> {
const id = nextRequestId++;
const callerUserId = requestActor.getStore()?.userId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
port!.postMessage({
id,
method,
args,
...(callerUserId && { callerUserId }),
});
});
}
function handleResponse(message: PluginResponse): void {
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
if (message.ok === true) {
waiter.resolve(message.value);
return;
}
const failure = message.error;
const error = new Error(failure.message);
if (failure.code) {
(error as Error & { code?: string }).code = failure.code;
}
waiter.reject(error);
}
async function handlePush(push: PluginPush): Promise<void> {
if (push.kind === "deactivate") {
// Always acknowledge, even when the plugin has no hook or its hook threw:
// the main thread is waiting before it terminates us, and a missing reply
// would just stall that until its timeout.
try {
if (pluginDeactivate) await pluginDeactivate();
} catch {
// Nothing useful to do here; the worker is about to be terminated.
}
if (push.replyTo !== undefined) {
port!.postMessage({ id: push.replyTo, ok: true, value: null });
}
return;
}
if (push.kind === "event") {
const listeners = eventListeners.get(push.key);
if (!listeners) return;
for (const listener of listeners) {
try {
listener(push.payload);
} catch {
// A throwing listener must not take down the worker.
}
}
return;
}
if (push.kind === "timer") {
const handler = timers.get(push.key);
if (handler) {
try {
handler();
} catch {
// same
}
}
return;
}
if (push.kind === "http") {
const handler = httpRoutes.get(push.key);
if (push.replyTo === undefined) return;
if (!handler) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: { message: `No handler registered for route ${push.key}` },
});
return;
}
const requestUserId =
(push.payload as { userId?: string | null } | undefined)?.userId ??
undefined;
try {
const value = await (requestUserId
? requestActor.run({ userId: requestUserId }, () =>
handler(push.payload),
)
: handler(push.payload));
port!.postMessage({ id: push.replyTo, ok: true, value });
} catch (error) {
port!.postMessage({
id: push.replyTo,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}
port.on("message", (message: unknown) => {
if (isPluginResponse(message)) {
handleResponse(message);
return;
}
const push = message as PluginPush;
if (push && typeof push.kind === "string") {
void handlePush(push);
}
});
function notImplemented(name: string) {
return () =>
Promise.reject(
new Error(
`ctx.${name} is not implemented in plugin SDK version 1. It is reserved and will be added in a later version.`,
),
);
}
const ctx = {
pluginId: boot.pluginId,
manifest: boot.manifest,
log: {
debug: (message: string, context?: unknown) =>
call("log.debug", message, context),
info: (message: string, context?: unknown) =>
call("log.info", message, context),
warn: (message: string, context?: unknown) =>
call("log.warn", message, context),
error: (message: string, context?: unknown) =>
call("log.error", message, context),
},
audit: (action: string, details?: unknown) => call("audit", action, details),
hosts: {
list: () => call("hosts.list"),
get: (hostId: number) => call("hosts.get", hostId),
},
ssh: {
connect: (hostId: number) => call("ssh.connect", hostId),
exec: (handle: string, command: string, timeoutMs?: number) =>
call("ssh.exec", handle, command, timeoutMs),
close: (handle: string) => call("ssh.close", handle),
},
storage: {
get: (key: string) => call("storage.get", key),
set: (key: string, value: unknown) => call("storage.set", key, value),
delete: (key: string) => call("storage.delete", key),
list: () => call("storage.list"),
},
http: {
route: (
method: string,
routePath: string,
handler: (req: unknown) => unknown,
) => {
const key = `${method.toUpperCase()} ${routePath}`;
httpRoutes.set(key, handler);
return call("http.route", method.toUpperCase(), routePath);
},
},
events: {
emit: (topic: string, payload: unknown) =>
call("events.emit", topic, payload),
on: async (topic: string, listener: (payload: unknown) => void) => {
let listeners = eventListeners.get(topic);
if (!listeners) {
listeners = new Set();
eventListeners.set(topic, listeners);
await call("events.subscribe", topic);
}
listeners.add(listener);
return () => {
listeners!.delete(listener);
};
},
},
schedule: {
every: async (intervalMs: number, handler: () => void) => {
const id = (await call("schedule.every", intervalMs)) as string;
timers.set(id, handler);
return id;
},
cancel: async (id: string) => {
timers.delete(id);
await call("schedule.cancel", id);
},
},
secrets: {
get: notImplemented("secrets.get"),
set: notImplemented("secrets.set"),
// Cross-plugin sharing is in-process only for now. Named here rather than
// left undefined so a worker plugin that tries it gets the same explicit
// error as any other unimplemented ctx call.
offer: notImplemented("secrets.offer"),
withdraw: notImplemented("secrets.withdraw"),
getShared: notImplemented("secrets.getShared"),
},
notify: notImplemented("notify"),
fetch: notImplemented("fetch"),
};
/**
* Held so a deactivate push can call it. A worker plugin is still terminated
* afterwards either way -- this only gives it a chance to close things
* cleanly first, which matters for anything holding a remote resource.
*/
let pluginDeactivate: (() => void | Promise<void>) | null = null;
async function start(): Promise<void> {
const module = await import(boot.entryPath);
const activate = module.activate ?? module.default?.activate;
const deactivate = module.deactivate ?? module.default?.deactivate;
if (typeof activate !== "function") {
throw new Error(
`Plugin ${boot.pluginId} backend entry does not export an activate(ctx) function`,
);
}
if (typeof deactivate === "function") pluginDeactivate = deactivate;
await activate(ctx);
port!.postMessage({ id: 0, ok: true, value: "activated" });
}
start().catch((error) => {
port!.postMessage({
id: 0,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
});
@@ -1,20 +1,19 @@
/**
* database.ts mounts /plugin-api behind authenticateJWT, the same way every
* other router is gated. This exercises that exact wiring (rather than
* http-bridge.test.ts's bare app, which mounts the dispatcher with no auth
* in front of it on purpose, to test the dispatcher in isolation) so a
* regression here - an unauthenticated caller reaching a plugin route - is
* caught.
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
* every other router is gated, so an unauthenticated caller can never reach
* plugin code.
*
* The dispatcher itself has no routes registered in A1: the worker HTTP
* bridge was removed with the worker tier and A4 rebuilds routing on top of
* the SDK. What still has to hold, and what this covers, is that the auth
* gate sits in front of the mount and that an unknown plugin id is a 404
* rather than a crash.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, 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 "../../plugins/fixture-plugin.js";
const state = vi.hoisted(() => ({
validToken: "valid-token",
@@ -22,13 +21,6 @@ const state = vi.hoisted(() => ({
}));
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(),
@@ -38,133 +30,108 @@ vi.mock("../../../utils/logger.js", () => ({
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown> & { headers: Record<string, string> },
res: { status: (code: number) => { json: (body: unknown) => void } },
next: () => void,
) => {
const auth = req.headers["authorization"];
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
if (token !== state.validToken) {
res.status(401).json({ error: "Missing authentication token" });
return;
}
req.userId = state.userId;
next();
},
}),
},
}));
const { AuthManager } = await import("../../../utils/auth-manager.js");
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 WORKER_TIMEOUT = 30_000;
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
function authenticateJWT(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
describe("/plugin-api behind the app's real auth middleware", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
if (token !== state.validToken) {
res.status(401).json({ error: "Unauthorized" });
return;
}
beforeEach(async () => {
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
(req as express.Request & { userId?: string }).userId = state.userId;
next();
}
const app = express();
// Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes).
app.use("/plugin-api", authenticateJWT, pluginApi.default);
let server: Server | null = null;
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
async function startServer(): Promise<string> {
const app = express();
// The same order database.ts uses: the gate, then the dispatcher.
app.use("/plugin-api", authenticateJWT, pluginApi.default);
return new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", () => {
const { port } = server!.address() as AddressInfo;
resolve(`http://127.0.0.1:${port}`);
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/whoami", async (req) => ({
userId: req.userId ?? null,
}));
}
`,
});
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");
});
}
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()));
afterEach(async () => {
if (server) {
await new Promise((resolve) => server!.close(() => resolve(undefined)));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
}
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
});
describe("/plugin-api authentication", () => {
it("rejects a request with no token", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(response.status).toBe(401);
});
it(
"rejects a request with no token before it reaches the plugin",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`);
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
it("rejects a request with the wrong token", async () => {
const base = await startServer();
it(
"rejects a request with an invalid token",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: "Bearer not-the-right-token" },
});
expect(res.status).toBe(401);
},
WORKER_TIMEOUT,
);
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: "Bearer nope" },
});
it(
"forwards the verified userId once authenticated",
async () => {
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, {
headers: { authorization: `Bearer ${state.validToken}` },
expect(response.status).toBe(401);
});
// Past the gate, an id nothing registered is a plain 404. The auth check
// runs first either way, so a caller cannot use the difference between 401
// and 404 to find out which plugins are installed.
it("returns 404 for an unregistered plugin id when authenticated", async () => {
const base = await startServer();
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(response.status).toBe(404);
});
it("dispatches to a registered router only when authenticated", async () => {
const router = express.Router();
router.get("/thing", (req, res) => {
res.json({
ok: true,
userId: (req as express.Request & { userId?: string }).userId,
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: state.userId });
},
WORKER_TIMEOUT,
);
});
pluginApi.registerPluginRouter("sample-plugin", router);
const base = await startServer();
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
expect(unauthorized.status).toBe(401);
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(authorized.status).toBe(200);
// The user id the plugin sees is the one the gate verified, never one the
// caller supplied.
expect(await authorized.json()).toEqual({
ok: true,
userId: state.userId,
});
});
});
@@ -17,12 +17,18 @@ interface PluginRow {
tier: string;
source: string;
state: string;
lastError?: string | null;
manifestJson: string;
}
const state = vi.hoisted(() => ({
plugins: new Map<string, PluginRow>(),
grants: [] as { pluginId: string; capability: string; grantedBy: string }[],
grants: [] as {
pluginId: string;
capability: string;
grantedBy: string | null;
source?: string;
}[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
}));
@@ -62,6 +68,9 @@ vi.mock("../../../utils/auth-manager.js", () => ({
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
// The list route asks directly rather than gating the whole mount, so
// it can return a narrower shape to a non-admin instead of a 403.
hasPermission: async (userId: string) => state.managers.has(userId),
requirePermission:
(_permission: string) =>
(
@@ -113,7 +122,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
grant: async (input: {
pluginId: string;
capability: string;
grantedBy: string;
grantedBy: string | null;
source?: string;
}) => {
state.grants.push(input);
return input;
@@ -140,7 +150,7 @@ function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
source: "community",
state: "enabled",
manifestJson: JSON.stringify({
permissions: ["hosts.read", "storage.own"],
capabilities: ["hosts:read", "kv:own"],
}),
...overrides,
};
@@ -170,11 +180,11 @@ describe("plugins route", () => {
server = null;
});
it("lists a plugin's declared permissions and granted capabilities", async () => {
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
@@ -184,8 +194,8 @@ describe("plugins route", () => {
expect(body).toEqual([
expect.objectContaining({
id: "sample-plugin",
permissions: ["hosts.read", "storage.own"],
grantedCapabilities: ["hosts.read"],
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
}),
]);
});
@@ -196,19 +206,19 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: true,
});
expect(state.grants).toEqual([
{
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
},
]);
@@ -231,7 +241,7 @@ describe("plugins route", () => {
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(404);
@@ -243,12 +253,12 @@ describe("plugins route", () => {
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(state.grants).toHaveLength(1);
@@ -258,19 +268,19 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
granted: false,
});
expect(state.grants).toEqual([]);
@@ -280,7 +290,7 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
@@ -288,7 +298,7 @@ describe("plugins route", () => {
});
describe("admin.plugins.manage gate", () => {
it("a non-admin authenticated user cannot list plugins", async () => {
it("lets a non-admin list plugins", async () => {
// GET stays open to any authenticated user on purpose: the app shell
// calls it for every session to know which plugin tabs to register.
state.plugins.set("sample-plugin", makePlugin());
@@ -300,6 +310,58 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
});
// What a plugin may do, what it has been granted and why it failed are
// operational details. The shell needs none of them to render a tab.
it("hides capabilities, grants and lastError from a non-admin", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const [plugin] = await res.json();
expect(plugin).toEqual({
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
enabled: true,
state: "enabled",
contributes: null,
});
});
it("shows the full record to a holder of admin.plugins.manage", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "admin-1" },
});
const [plugin] = await res.json();
expect(plugin).toMatchObject({
id: "sample-plugin",
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
lastError: "port already in use",
});
});
it("403s a non-admin enabling or disabling a plugin", async () => {
state.plugins.set("sample-plugin", makePlugin());
@@ -325,7 +387,7 @@ describe("plugins route", () => {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ capability: "hosts.read" }),
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(403);
@@ -336,12 +398,12 @@ describe("plugins route", () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/hosts.read`,
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{
method: "DELETE",
headers: { "x-test-user-id": "regular-user" },
-620
View File
@@ -1,620 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
auditEntries: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async (params: Record<string, unknown>) => {
state.auditEntries.push(params);
}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/**
* A host as resolveHostById would return it: secrets in plaintext. The tests
* assert none of this reaches the worker.
*/
const SECRET = "hunter2-plaintext-secret";
const RAW_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
// None of these may ever cross the boundary.
password: SECRET,
key: SECRET,
keyPassword: SECRET,
certPublicKey: SECRET,
sudoPassword: SECRET,
autostartPassword: SECRET,
};
describe("PluginBroker", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [];
state.auditEntries = [];
storage = memoryStorage();
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
// The bus is a module singleton, so a leftover listener would leak between
// tests.
const { pluginEvents } = await import("../../plugins/events.js");
pluginEvents.clear();
while (fixtures.length) fixtures.pop()!.cleanup();
});
function grant(capability: string, pluginId = "sample-plugin") {
state.grants.push({ pluginId, capability });
invalidatePluginPermissionCache(pluginId);
}
/**
* Boots a real worker whose activate() runs `body` and reports the outcome
* back through a ctx.log call the test can read.
*/
/** In-memory stand-in for PluginStorageRepository, scoped per plugin. */
function memoryStorage() {
const rows = new Map<string, string>();
const at = (pluginId: string, key: string) => `${pluginId}\u0000${key}`;
return {
rows,
backend: {
get: async (pluginId: string, key: string) =>
rows.get(at(pluginId, key)) ?? null,
set: async (pluginId: string, key: string, value: string) => {
rows.set(at(pluginId, key), value);
},
delete: async (pluginId: string, key: string) =>
rows.delete(at(pluginId, key)),
listKeys: async (pluginId: string) =>
[...rows.keys()]
.filter((k) => k.startsWith(`${pluginId}\u0000`))
.map((k) => k.split("\u0000")[1]),
},
};
}
let storage = memoryStorage();
async function boot(body: string, permissions?: string[]) {
const fixture = createFixturePlugin({
permissions: permissions ?? ["hosts.read", "storage.own"],
backendSource: `
export async function activate(ctx) {
globalThis.__result = undefined;
try {
globalThis.__result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
globalThis.__result = { ok: false, error: error.message };
}
await ctx.log.info(JSON.stringify(globalThis.__result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RAW_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RAW_HOST : null),
storage: storage.backend,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result reported" };
}
describe("permission gate", () => {
it(
"allows a call whose capability is declared and granted",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(result.value).toMatchObject({ id: 42, name: "prod-box" });
},
WORKER_TIMEOUT,
);
it(
"denies a declared capability that was never granted",
async () => {
const result = await boot(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"denies a granted capability the manifest never declared",
async () => {
// Granted in the database, but absent from the manifest: widening a
// plugin's reach must require a new manifest, not just a grant.
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(42);`, [
"storage.own",
]);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "hosts.read"');
},
WORKER_TIMEOUT,
);
it(
"rejects an unknown ctx method",
async () => {
const result = await boot(
`return await new Promise((resolve, reject) => {
ctx.hosts.__proto__; resolve("noop");
});`,
);
expect(result.ok).toBe(true);
},
WORKER_TIMEOUT,
);
});
describe("credential isolation", () => {
it(
"never sends credential material to the worker",
async () => {
grant("hosts.read");
const result = await boot(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const blob = JSON.stringify({ host, list });
if (blob.includes(${JSON.stringify(SECRET)})) {
throw new Error("SECRET LEAKED: " + blob);
}
return { keys: Object.keys(host).sort(), listLength: list.length };
`);
expect(result.ok).toBe(true);
expect(result.value.listLength).toBe(1);
expect(result.value.keys).not.toContain("password");
expect(result.value.keys).not.toContain("key");
expect(result.value.keys).not.toContain("keyPassword");
expect(result.value.keys).not.toContain("certPublicKey");
expect(result.value.keys).not.toContain("sudoPassword");
expect(result.value.keys).not.toContain("autostartPassword");
},
WORKER_TIMEOUT,
);
it(
"returns null rather than distinguishing missing from forbidden",
async () => {
grant("hosts.read");
const result = await boot(`return await ctx.hosts.get(999);`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
});
describe("audit", () => {
it(
"writes one plugin-attributed line per permitted call",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toBeDefined();
expect(entry).toMatchObject({
userId: "user-1",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
},
WORKER_TIMEOUT,
);
it(
"audits a denied call as a failure",
async () => {
await boot(`return await ctx.hosts.get(42);`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
expect(entry).toMatchObject({
username: "plugin:sample-plugin",
success: false,
});
expect(String(entry?.errorMessage)).toContain("is not granted");
},
WORKER_TIMEOUT,
);
it(
"does not let a plugin forge the actor or smuggle bulk details",
async () => {
grant("hosts.read");
// ctx.log takes a context object, so this exercises the summariser on
// an argument a plugin genuinely controls.
await boot(`
await ctx.log.info("hello", { username: "admin", userId: "user-999" });
return await ctx.hosts.get(42);
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
// Attribution is broker-set regardless of what the plugin passed.
expect(entry?.username).toBe("plugin:sample-plugin");
expect(entry?.userId).toBe("user-1");
// No audit entry anywhere claims the user the plugin named.
expect(
state.auditEntries.some(
(e) => e.userId === "user-999" || e.username === "admin",
),
).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"summarises rather than records a long string argument",
async () => {
grant("hosts.read");
await boot(`
try { await ctx.hosts.get("${"x".repeat(500)}"); } catch {}
return "done";
`);
const entry = state.auditEntries.find(
(e) => e.action === "plugin_hosts_get",
);
const details = String(entry?.details);
// Capped, so a plugin cannot write bulk data into the audit trail.
expect(details.length).toBeLessThan(200);
expect(details).not.toContain("x".repeat(200));
},
WORKER_TIMEOUT,
);
});
describe("ctx.storage", () => {
it(
"round-trips a structured value",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("prefs", { theme: "dark", count: 3 });
const read = await ctx.storage.get("prefs");
const keys = await ctx.storage.list();
return { read, keys };
`);
expect(result.ok).toBe(true);
expect(result.value.read).toEqual({ theme: "dark", count: 3 });
expect(result.value.keys).toEqual(["prefs"]);
},
WORKER_TIMEOUT,
);
it(
"returns null for a key that was never set",
async () => {
grant("storage.own");
const result = await boot(`return await ctx.storage.get("nope");`);
expect(result.ok).toBe(true);
expect(result.value).toBeNull();
},
WORKER_TIMEOUT,
);
it(
"deletes a key",
async () => {
grant("storage.own");
const result = await boot(`
await ctx.storage.set("temp", 1);
await ctx.storage.delete("temp");
return await ctx.storage.list();
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"scopes writes to the calling plugin, which cannot name another scope",
async () => {
grant("storage.own");
// A plugin only ever supplies a key; the plugin id is added by the
// broker, so there is no argument through which to reach another
// plugin's rows.
await boot(`await ctx.storage.set("owned", "value");`);
const keys = [...storage.rows.keys()];
expect(keys).toEqual(["sample-plugin\u0000owned"]);
},
WORKER_TIMEOUT,
);
it(
"denies storage without the granted capability",
async () => {
const result = await boot(`return await ctx.storage.get("prefs");`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "storage.own"');
},
WORKER_TIMEOUT,
);
it(
"rejects an empty key and an oversized value",
async () => {
grant("storage.own");
const result = await boot(`
const errors = [];
try { await ctx.storage.set("", 1); } catch (e) { errors.push(e.message); }
try {
await ctx.storage.set("big", "x".repeat(300000));
} catch (e) { errors.push(e.message); }
return errors;
`);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain("non-empty key");
expect(result.value[1]).toContain("byte limit");
},
WORKER_TIMEOUT,
);
});
describe("ctx.schedule", () => {
it(
"registers a timer and fires the plugin's handler",
async () => {
grant("storage.own");
const result = await boot(`
await new Promise(async (resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timer never fired")), 8000);
await ctx.schedule.every(1000, async () => {
clearTimeout(timer);
resolve();
});
});
return "fired";
`);
expect(result.ok).toBe(true);
expect(result.value).toBe("fired");
},
WORKER_TIMEOUT,
);
it(
"rejects an interval below the floor",
async () => {
grant("storage.own");
const result = await boot(
`return await ctx.schedule.every(5, () => {});`,
);
expect(result.ok).toBe(false);
expect(result.error).toContain("at least 1000ms");
},
WORKER_TIMEOUT,
);
it(
"cancel stops the timer and drops it from the runtime",
async () => {
grant("storage.own");
await boot(`
const id = await ctx.schedule.every(1000, () => {});
await ctx.schedule.cancel(id);
`);
expect(broker!.get("sample-plugin")!.timers.size).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("ctx.events", () => {
it(
"pushes a subscribed topic through to the plugin's listener",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
// The plugin subscribes during activate, then reports what it sees on
// the next event. boot() resolves once activate returns, so the emit
// below happens with the subscription already live.
await boot(
`
await ctx.events.on("host.status", async (payload) => {
await ctx.log.info("EVENT:" + JSON.stringify(payload));
});
`,
["events.read"],
);
const runtime = broker!.get("sample-plugin")!;
expect(runtime.subscriptions.has("host.status")).toBe(true);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
const pushed: unknown[] = [];
const original = runtime.worker.postMessage.bind(runtime.worker);
runtime.worker.postMessage = ((message: unknown) => {
pushed.push(message);
return original(message);
}) as typeof runtime.worker.postMessage;
pluginEvents.emit("host.status", { hostId: 42, online: true });
expect(pushed).toContainEqual({
kind: "event",
key: "host.status",
payload: { hostId: 42, online: true },
});
},
WORKER_TIMEOUT,
);
it(
"refuses to emit outside the plugin's own namespace",
async () => {
grant("events.read");
const result = await boot(
`
const errors = [];
try {
await ctx.events.emit("host.status", { hostId: 1, online: false });
} catch (e) { errors.push(e.message); }
await ctx.events.emit("plugin.sample-plugin.ready", { ok: true });
return errors;
`,
["events.read"],
);
expect(result.ok).toBe(true);
expect(result.value[0]).toContain(
'may only publish topics beginning with "plugin.sample-plugin."',
);
},
WORKER_TIMEOUT,
);
it(
"denies subscribing without the events.read capability",
async () => {
const result = await boot(
`return await ctx.events.on("host.status", () => {});`,
["events.read"],
);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "events.read"');
},
WORKER_TIMEOUT,
);
it(
"drops bus subscriptions when the plugin detaches",
async () => {
grant("events.read");
const { pluginEvents } = await import("../../plugins/events.js");
await boot(`await ctx.events.on("host.status", () => {});`, [
"events.read",
]);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
broker!.detach("sample-plugin");
expect(pluginEvents.listenerCount("host.status")).toBe(0);
},
WORKER_TIMEOUT,
);
});
describe("detach", () => {
it(
"releases ssh handles and timers when the worker goes away",
async () => {
grant("hosts.read");
await boot(`return await ctx.hosts.get(42);`);
const runtime = broker!.get("sample-plugin");
expect(runtime).toBeDefined();
let released = false;
runtime!.sshHandles.set("sshconn:test", {
hostId: 42,
release: () => {
released = true;
},
});
runtime!.timers.set(
"t1",
setInterval(() => {}, 10_000),
);
broker!.detach("sample-plugin");
expect(released).toBe(true);
expect(broker!.get("sample-plugin")).toBeUndefined();
},
WORKER_TIMEOUT,
);
});
});
+93 -414
View File
@@ -1,8 +1,9 @@
/**
* The real bundled ssh-terminal plugin, as it ships.
* The bundled plugins, as they ship.
*
* This reads plugins/ in the repo rather than a fixture: the manifest is a
* checked-in artifact, and a typo in it would otherwise only show up at boot.
* This reads plugins/ in the repo rather than a fixture: the manifests are
* checked-in artifacts, and a typo in one would otherwise only show up when
* the server tried to boot.
*/
import { describe, expect, it, vi } from "vitest";
@@ -10,11 +11,7 @@ import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "../../plugins/paths.js";
import { parseManifest } from "../../plugins/manifest.js";
import {
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
@@ -26,443 +23,125 @@ vi.mock("../../utils/logger.js", () => ({
},
}));
function readBundledManifest(pluginId: string): Record<string, unknown> {
const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json");
return JSON.parse(fs.readFileSync(file, "utf8"));
const BUNDLED_DIR = getBundledPluginsDir();
function bundledIds(): string[] {
return fs
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
}
describe("bundled ssh-terminal plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "ssh-terminal");
function readManifest(pluginId: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
);
}
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
const ids = bundledIds();
describe("bundled plugins", () => {
it("finds the bundled plugin directory", () => {
expect(ids.length).toBeGreaterThan(0);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("ssh-terminal"),
);
it.each(ids)("%s has a manifest that parses", (id) => {
const { manifest, errors } = parseManifest(readManifest(id));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("ssh-terminal");
expect(manifest?.category).toBe("Terminal");
expect(manifest?.id).toBe(id);
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
it.each(ids)("%s declares only catalog capabilities", (id) => {
const { manifest } = parseManifest(readManifest(id));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true);
for (const capability of manifest?.capabilities ?? []) {
expect(isKnownCapability(capability)).toBe(true);
}
});
it("contributes the terminal tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
const tab = manifest?.contributes?.tabs?.[0];
it.each(ids)("%s ships the entry points its manifest names", (id) => {
const { manifest } = parseManifest(readManifest(id));
const dir = path.join(BUNDLED_DIR, id);
// The shell keys tab content off this id, so it has to stay "terminal".
expect(tab?.id).toBe("terminal");
expect(tab?.openFrom).toContain("rail");
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"ssh-terminal",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
// process:transport-owner was the v1 marker for the in-process tier. There
// is one tier now, so nothing should still be asking for it.
it.each(ids)("%s does not declare a v1 permission", (id) => {
const raw = readManifest(id);
// A first-party plugin owns a listening port, so deactivate is not
// optional here the way it is for an ordinary plugin.
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(raw.permissions).toBeUndefined();
expect(raw.sidecars).toBeUndefined();
expect(JSON.stringify(raw)).not.toContain("transport-owner");
});
});
describe("bundled docker plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "docker");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
describe("declared dependencies", () => {
it("names a plugin that is actually bundled", () => {
for (const id of ids) {
const { manifest } = parseManifest(readManifest(id));
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
expect(ids).toContain(dependency);
}
}
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("docker"));
// plugins/ai/backend/tools/executor.ts imports automations' routes
// directly, so the loader has to start automations first. B18 replaces the
// import with a service call and this becomes optional.
it("records ai's dependency on automations", () => {
if (!ids.includes("ai")) return;
expect(errors).toEqual([]);
expect(manifest?.id).toBe("docker");
expect(manifest?.category).toBe("Infrastructure");
});
const { manifest } = parseManifest(readManifest("ai"));
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("docker")).toBe(true);
expect(runsInProcess("docker", manifest!.permissions)).toBe(true);
});
it("contributes the docker tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "docker".
expect(tab?.id).toBe("docker");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("declares the enableDocker host capability", () => {
const { manifest } = parseManifest(readBundledManifest("docker"));
expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"docker",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(manifest?.dependencies?.automations).toBeTruthy();
});
});
describe("bundled host-metrics plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "host-metrics");
describe("plugins that own a port", () => {
// Every one of these had its server created at module scope at some point.
// The rule is that it belongs inside activate, or disable leaves the port
// held and re-enable fails.
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
"%s creates no listener at module scope",
(id) => {
if (!ids.includes(id)) return;
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs");
const source = fs.readFileSync(entry, "utf8");
const moduleScope = source.slice(
0,
source.indexOf("export async function activate"),
);
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("host-metrics"),
);
expect(moduleScope).not.toMatch(/\.listen\(/);
expect(moduleScope).not.toMatch(/new WebSocketServer/);
},
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("host-metrics");
expect(manifest?.category).toBe("Monitoring");
});
it("docker does not register a process signal handler", () => {
if (!ids.includes("docker")) return;
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
// every plugin's deactivate. A plugin calling process.exit() itself
// skipped the rest of that sequence.
for (const file of ["index.mjs", "console.ts"]) {
const candidate = path.join(BUNDLED_DIR, "docker", "backend", file);
if (!fs.existsSync(candidate)) continue;
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("host-metrics")).toBe(true);
expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true);
});
// Comments are stripped first: the rule is worth explaining in prose
// right where it applies, and that prose names the thing it forbids.
const code = fs
.readFileSync(candidate, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
it("contributes the host-metrics tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "host-metrics".
expect(tab?.id).toBe("host-metrics");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled remote-desktop plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "remote-desktop");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("remote-desktop"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("remote-desktop");
expect(manifest?.category).toBe("Terminal");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true);
});
it("contributes the rdp/vnc/telnet tabs the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id);
expect(tabIds).toEqual(["rdp", "vnc", "telnet"]);
});
it("declares three host capabilities as an array", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const hostCapability = manifest?.contributes?.hostCapability;
expect(Array.isArray(hostCapability)).toBe(true);
expect(
(hostCapability as Array<{ key: string }>).map((entry) => entry.key),
).toEqual(["enableRdp", "enableVnc", "enableTelnet"]);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"remote-desktop",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled fleets plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "fleets");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(readBundledManifest("fleets"));
expect(errors).toEqual([]);
expect(manifest?.id).toBe("fleets");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("fleets"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("fleets")).toBe(true);
expect(runsInProcess("fleets", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"fleets",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled automations plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "automations");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("automations"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("automations");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("automations"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("automations")).toBe(true);
expect(runsInProcess("automations", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"automations",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled network-topology plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "network-topology");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("network-topology"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("network-topology");
expect(manifest?.category).toBe("Infrastructure");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("network-topology")).toBe(true);
expect(runsInProcess("network-topology", manifest!.permissions)).toBe(true);
});
it("contributes the network_graph tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("network-topology"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "network_graph".
expect(tab?.id).toBe("network_graph");
expect(tab?.openFrom).toContain("rail");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"network-topology",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled workspaces plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "workspaces");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("workspaces"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("workspaces");
expect(manifest?.category).toBe("Productivity");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("workspaces"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("workspaces")).toBe(true);
expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"workspaces",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled web-endpoint plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("web-endpoint"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("web-endpoint");
expect(manifest?.category).toBe("Networking");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
});
// The column this points at stays on core's ssh_data table, so a typo here
// would silently detach the host-editor checkbox from its storage.
it("contributes the enableWebUi host capability", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.contributes?.hostCapability).toMatchObject({
key: "enableWebUi",
});
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"web-endpoint",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
expect(code).not.toMatch(/process\.exit\(/);
}
});
});
@@ -0,0 +1,234 @@
/**
* The capability gate.
*
* Two conditions, both required: the manifest declares it and it is granted.
* Either missing is a refusal, and both the refusal and the allowed call are
* audited with the plugin id and the acting user, because "who did this"
* being answerable is the point of the gate.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const kvStore = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginStorageRepository: () => ({
get: async (pluginId: string, key: string) =>
kvStore.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId: string, key: string, value: string) => {
kvStore.set(`${pluginId}:${key}`, value);
},
delete: async (pluginId: string, key: string) =>
kvStore.delete(`${pluginId}:${key}`),
listKeys: async () => [...kvStore.keys()],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const { createPluginContext, createPluginHandle } =
await import("../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const { runAsActor } = await import("../../plugins/actor.js");
function manifest(capabilities: string[]) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as never;
}
function context(declared: string[]) {
const value = manifest(declared);
const handle = createPluginHandle("sample-plugin", {
activate: () => {},
});
return createPluginContext(value, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
kvStore.clear();
invalidatePluginPermissionCache();
});
describe("capability gate", () => {
it("denies a capability the manifest never declared", async () => {
// Granted in the database, but absent from the manifest. Widening a
// plugin's reach must always require a new manifest the user can see.
grants.set("sample-plugin", ["kv:own"]);
const ctx = context([]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("denies a declared capability that has not been granted", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("anything")).rejects.toThrow(
/not granted the "kv:own" capability/,
);
});
it("allows a capability that is both declared and granted", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await ctx.kv.set("colour", "blue");
await expect(ctx.kv.get("colour")).resolves.toBe("blue");
});
it("audits an allowed call with the plugin id and the actor", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
await runAsActor("user-42", "request", () => ctx.kv.set("k", 1));
const entry = auditEntries.find((e) => e.action === "plugin_kv_set");
expect(entry).toMatchObject({
userId: "user-42",
username: "plugin:sample-plugin",
resourceType: "plugin",
resourceId: "sample-plugin",
success: true,
});
});
// A refused call is exactly the thing an operator wants to find later.
it("audits a denied call as a failure", async () => {
const ctx = context(["kv:own"]);
await expect(
runAsActor("user-42", "request", () => ctx.kv.get("k")),
).rejects.toThrow();
const entry = auditEntries.find((e) => e.action === "plugin_kv_get");
expect(entry).toMatchObject({ userId: "user-42", success: false });
expect(String(entry?.errorMessage)).toMatch(/not granted/);
});
it("picks up a grant made after the first denial", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.get("k")).rejects.toThrow();
grants.set("sample-plugin", ["kv:own"]);
invalidatePluginPermissionCache("sample-plugin");
await expect(ctx.kv.get("k")).resolves.toBeNull();
});
});
describe("ctx.asUser", () => {
it("makes the named user the actor and audits the switch", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
let seen: string | undefined;
await ctx.asUser("user-7", async () => {
seen = ctx.currentActor();
await ctx.kv.set("k", 1);
});
expect(seen).toBe("user-7");
expect(
auditEntries.find((e) => e.action === "plugin_as_user"),
).toMatchObject({ resourceId: "sample-plugin", success: true });
expect(
auditEntries.find((e) => e.action === "plugin_kv_set"),
).toMatchObject({ userId: "user-7" });
});
it("restores the previous actor afterwards", async () => {
const ctx = context([]);
await runAsActor("outer-user", "request", async () => {
await ctx.asUser("inner-user", async () => {
expect(ctx.currentActor()).toBe("inner-user");
});
expect(ctx.currentActor()).toBe("outer-user");
});
});
it("refuses an empty user id", async () => {
const ctx = context([]);
await expect(ctx.asUser("", async () => {})).rejects.toThrow(
/without a user id/,
);
});
});
describe("ctx.events namespacing", () => {
it("refuses a core topic without events:core", () => {
const ctx = context(["kv:own"]);
// Without this a plugin could publish host.status and drive the
// automations engine as though core had.
expect(() => ctx.events.emit("host.status", {})).toThrow(
/may only emit topics under/,
);
});
it("allows the plugin's own namespace", () => {
const ctx = context(["kv:own"]);
expect(() =>
ctx.events.emit("plugin.sample-plugin.thing", {}),
).not.toThrow();
});
it("allows a core topic when events:core is declared", () => {
const ctx = context(["events:core"]);
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
});
});
describe("ctx.kv limits", () => {
beforeEach(() => {
grants.set("sample-plugin", ["kv:own"]);
});
it("rejects an over-long key", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
/at most 128 characters/,
);
});
it("rejects a value over the size cap", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
/byte limit/,
);
});
});
@@ -1,365 +0,0 @@
/**
* The security test for the plugin runtime.
*
* The claim under test: a plugin worker cannot obtain plaintext credential
* material through ANY ctx method. Rather than assert field-by-field, this
* plants a unique sentinel string in every secret-bearing field of the resolved
* host and then asserts that the sentinel appears in nothing the worker ever
* receives -- return values, thrown error messages, or the ctx object itself.
*
* Every message crossing the boundary is recorded, so the assertion covers the
* wire, not just what the plugin happened to look at.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const SENTINEL = "SENT1NEL-plaintext-credential-do-not-leak";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
/** Every message the main thread sent to a worker. */
outbound: [] as unknown[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
/** A host exactly as resolveHostById hands it over: secrets in the clear. */
const RESOLVED_HOST = {
id: 42,
name: "prod-box",
ip: "10.0.0.5",
port: 22,
username: "root",
folder: "Production",
tags: ["prod"],
authType: "password",
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
password: SENTINEL,
key: SENTINEL,
keyPassword: SENTINEL,
certPublicKey: SENTINEL,
sudoPassword: SENTINEL,
autostartPassword: SENTINEL,
autostartKey: SENTINEL,
autostartKeyPassword: SENTINEL,
credentialId: 7,
vaultProfileId: 3,
};
function deepContains(value: unknown, needle: string): boolean {
if (typeof value === "string") return value.includes(needle);
if (value === null || typeof value !== "object") return false;
return Object.values(value as Record<string, unknown>).some((inner) =>
deepContains(inner, needle),
);
}
describe("plugin credential isolation", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
beforeEach(() => {
state.grants = [
{ pluginId: "sample-plugin", capability: "hosts.read" },
{ pluginId: "sample-plugin", capability: "ssh.exec" },
{ pluginId: "sample-plugin", capability: "storage.own" },
];
state.outbound = [];
invalidatePluginPermissionCache();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
/** Boots a plugin whose activate() runs `body` and reports the result. */
async function attack(body: string, sshOpenFails = false) {
const fixture = createFixturePlugin({
permissions: ["hosts.read", "ssh.exec", "storage.own"],
backendSource: `
export async function activate(ctx) {
let result;
try {
result = { ok: true, value: await (async () => { ${body} })() };
} catch (error) {
result = { ok: false, error: String(error && error.message) };
}
await ctx.log.info(JSON.stringify(result));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [RESOLVED_HOST],
resolveHost: async (hostId) => (hostId === 42 ? RESOLVED_HOST : null),
ssh: {
open: async () => {
if (sshOpenFails) {
// A failing connect must not spill the credential into the error
// message either: this mirrors ssh2 embedding config on failure.
throw new Error(
`Authentication failed for root using password ${SENTINEL}`,
);
}
return {
release: () => {},
exec: async () => ({
stdout: "uptime: 4 days",
stderr: "",
code: 0,
}),
};
},
},
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => {
// Record everything the main thread pushes at the worker.
const original = worker.postMessage.bind(worker);
worker.postMessage = ((message: unknown) => {
state.outbound.push(message);
return original(message);
}) as typeof worker.postMessage;
broker!.attach(plugin, worker);
},
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const line = logged.find((entry) => entry.startsWith("{"));
return line ? JSON.parse(line) : { ok: false, error: "no result" };
}
it(
"ctx.hosts.get returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.get(42);`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
for (const field of [
"password",
"key",
"keyPassword",
"certPublicKey",
"sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"credentialId",
"vaultProfileId",
]) {
expect(Object.keys(result.value)).not.toContain(field);
}
},
WORKER_TIMEOUT,
);
it(
"ctx.hosts.list returns no credential material",
async () => {
const result = await attack(`return await ctx.hosts.list();`);
expect(result.ok).toBe(true);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.connect returns an opaque handle, not a connection",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return {
handle,
type: typeof handle,
// Anything reachable from the handle, if it were an object.
reachable: typeof handle === "object" ? Object.keys(handle) : null,
};
`);
expect(result.ok).toBe(true);
expect(result.value.type).toBe("string");
expect(result.value.handle).toMatch(/^sshconn:/);
expect(result.value.handle).not.toContain(SENTINEL);
// The handle is random, so it cannot encode the host or its secrets.
expect(result.value.handle).not.toContain("10.0.0.5");
expect(result.value.handle).not.toContain("root");
},
WORKER_TIMEOUT,
);
it(
"ctx.ssh.exec returns only stdout, stderr and code",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
return await ctx.ssh.exec(handle, "uptime");
`);
expect(result.ok).toBe(true);
expect(Object.keys(result.value).sort()).toEqual([
"code",
"stderr",
"stdout",
]);
expect(deepContains(result.value, SENTINEL)).toBe(false);
},
WORKER_TIMEOUT,
);
it(
"a failing ctx.ssh.connect does not leak the credential in its error",
async () => {
const result = await attack(`return await ctx.ssh.connect(42);`, true);
expect(result.ok).toBe(false);
expect(result.error).not.toContain(SENTINEL);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot walk ctx itself to a credential",
async () => {
const result = await attack(`
const seen = new Set();
const found = [];
(function walk(value, path, depth) {
if (depth > 6 || value === null || seen.has(value)) return;
if (typeof value === "string") {
if (value.includes(${JSON.stringify(SENTINEL)})) found.push(path);
return;
}
if (typeof value !== "object" && typeof value !== "function") return;
seen.add(value);
for (const key of Object.getOwnPropertyNames(value)) {
let inner;
try { inner = value[key]; } catch { continue; }
walk(inner, path + "." + key, depth + 1);
}
})(ctx, "ctx", 0);
return found;
`);
expect(result.ok).toBe(true);
expect(result.value).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"no message sent to the worker ever contains credential material",
async () => {
await attack(`
const host = await ctx.hosts.get(42);
const list = await ctx.hosts.list();
const handle = await ctx.ssh.connect(42);
await ctx.ssh.exec(handle, "uptime");
await ctx.storage.set("cache", { host, list });
return await ctx.storage.get("cache");
`);
expect(state.outbound.length).toBeGreaterThan(0);
const leaking = state.outbound.filter((message) =>
deepContains(message, SENTINEL),
);
expect(leaking).toEqual([]);
},
WORKER_TIMEOUT,
);
it(
"a plugin cannot exec on a handle it did not open",
async () => {
const result = await attack(`
return await ctx.ssh.exec("sshconn:00000000-0000-0000-0000-000000000000", "id");
`);
expect(result.ok).toBe(false);
expect(result.error).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"a closed handle stops working",
async () => {
const result = await attack(`
const handle = await ctx.ssh.connect(42);
await ctx.ssh.close(handle);
try {
await ctx.ssh.exec(handle, "id");
return "still worked";
} catch (error) {
return error.message;
}
`);
expect(result.value).toContain("Unknown or closed SSH connection handle");
},
WORKER_TIMEOUT,
);
it(
"ssh.connect is denied without the granted capability",
async () => {
state.grants = [{ pluginId: "sample-plugin", capability: "hosts.read" }];
invalidatePluginPermissionCache();
const result = await attack(`return await ctx.ssh.connect(42);`);
expect(result.ok).toBe(false);
expect(result.error).toContain('is not granted the "ssh.exec"');
},
WORKER_TIMEOUT,
);
});
@@ -1,17 +1,18 @@
/**
* The central promise of shipping the terminal as a plugin: disabling it
* releases what it owns, and nothing else notices.
* Disabling a plugin releases what it owns.
*
* The SSH connection pool deliberately stayed in core, so a worker plugin's
* ctx.ssh keeps working with the terminal disabled. If the pool had moved into
* the terminal plugin, these tests would fail, which is the point of them.
* A plugin that keeps a port after deactivate makes "disabled" a lie and
* makes re-enabling fail on a port that is still held, which is exactly the
* bug the docker console had: its WebSocket server was created at module
* scope, so it survived deactivate and the second activate got nothing.
*
* These use a real listening socket rather than a mock, because the thing
* under test is whether the OS still has the port.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import net from "node:net";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry } from "../../plugins/registry.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
@@ -28,7 +29,7 @@ vi.mock("../../utils/logger.js", () => ({
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginPermissionError: class extends Error {},
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
@@ -45,136 +46,83 @@ function isListening(port: number): Promise<boolean> {
});
}
/** Stands in for the terminal: a first-party plugin that owns a real port. */
function terminalLikeSource(port: number): string {
/**
* A plugin that owns a real port, the way ssh-terminal and docker do.
*
* The server is created inside activate and closed through a disposable, not
* at module scope, which is the rule the runtime documents.
*/
function portOwningSource(port: number): string {
return `
import net from "node:net";
let server = null;
export async function activate(ctx) {
server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.registry.provide("terminal.sessions", { live: true });
}
export async function deactivate() {
if (!server) return;
await new Promise((resolve) => server.close(resolve));
server = null;
}
`;
import net from "node:net";
export async function activate(ctx) {
const server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.disposables.add(
() => new Promise((resolve) => server.close(() => resolve())),
);
}
`;
}
describe("disabling the terminal plugin", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
for (const fixture of fixtures.splice(0)) fixture.cleanup();
clearRegistry();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
});
it("frees its port and leaves another plugin's ctx.ssh working", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT),
describe("disabling a plugin releases its resources", () => {
async function start(): Promise<PluginLoader> {
fixture = createFixturePlugin({
backendSource: portOwningSource(TEST_PORT),
});
fixtures.push(terminal);
process.env.TERMIX_BUNDLED_PLUGINS_DIR = fixture.root;
process.env.DATA_DIR = fixture.root;
const other = createFixturePlugin({
id: "other-plugin",
permissions: ["ssh.exec"],
// Exposes an HTTP route the test invokes after the terminal is
// disabled, so the ctx.ssh round trip genuinely happens afterwards and
// travels the real worker boundary.
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/run", async () => {
const handle = await ctx.ssh.connect(1);
const result = await ctx.ssh.exec(handle, "uptime");
await ctx.ssh.close(handle);
return result;
});
}
`,
});
fixtures.push(other);
// Stands in for the core pool. The terminal plugin does not provide it,
// so disabling the terminal must not affect it.
const open = vi.fn().mockResolvedValue({
release: vi.fn(),
exec: vi
.fn()
.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }),
});
const broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
ssh: { open },
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
});
await loader.load(terminal.dir);
await loader.load(other.dir);
await loader.activate("ssh-terminal");
await loader.activate("other-plugin", "user-1");
const instance = new PluginLoader();
await instance.loadAll();
await instance.activate(fixture.id);
loader = instance;
return instance;
}
it("frees the port the plugin was listening on", async () => {
const instance = await start();
expect(await isListening(TEST_PORT)).toBe(true);
await loader.deactivate("ssh-terminal");
await instance.deactivate(fixture!.id);
// What the user sees: the terminal's port is gone.
expect(await isListening(TEST_PORT)).toBe(false);
expect(loader.get("ssh-terminal")?.state).toBe("stopped");
// And the other plugin is untouched, still running off the core pool.
const otherPlugin = loader.get("other-plugin");
expect(otherPlugin?.state).toBe("active");
// The real test: a ctx.ssh round trip completed AFTER the terminal was
// disabled, off the core pool, across the worker boundary.
const runtime = broker.get("other-plugin")!;
const result = (await broker.invokeRoute(runtime, "GET /run", {
method: "GET",
path: "/run",
params: {},
query: {},
body: null,
headers: {},
userId: "user-1",
})) as { stdout: string };
expect(open).toHaveBeenCalled();
expect(result.stdout).toBe("up 3 days");
});
it("can be re-enabled after being disabled", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT + 1),
});
fixtures.push(terminal);
// The regression the docker console hit: the module stays in the ESM cache,
// so the second activate runs against it and has to be able to bind again.
it("can take the port again after being re-enabled", async () => {
const instance = await start();
loader = new PluginLoader();
await loader.load(terminal.dir);
await instance.deactivate(fixture!.id);
expect(await isListening(TEST_PORT)).toBe(false);
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
await instance.activate(fixture!.id);
await loader.deactivate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(false);
expect(await isListening(TEST_PORT)).toBe(true);
expect(instance.get(fixture!.id)?.state).toBe("active");
});
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
it("frees the port on shutdown too", async () => {
const instance = await start();
await instance.shutdown();
loader = null;
expect(await isListening(TEST_PORT)).toBe(false);
});
});
@@ -1,279 +0,0 @@
/**
* The first-party tier is the one place in the plugin system where code runs
* without the worker boundary, so the thing most worth testing is that it
* cannot be entered by a plugin that says it should be.
*/
import { describe, expect, it } from "vitest";
import {
FIRST_PARTY_PLUGIN_IDS,
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
import {
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function manifest(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A plugin.",
author: { name: "Someone" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: [] as string[],
sidecars: [],
...overrides,
};
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
"docker",
"fleets",
"host-metrics",
"network-topology",
"proxmox",
"remote-desktop",
"ssh-terminal",
"tailscale",
"web-endpoint",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(isFirstParty("fleets")).toBe(true);
expect(isFirstParty("automations")).toBe(true);
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
});
});
describe("runsInProcess", () => {
it("requires both the allowlist and the declared capability", () => {
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(true);
expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
expect(runsInProcess("fleets", ["hosts.read"])).toBe(false);
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
runsInProcess("community-plugin", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(false);
expect(runsInProcess("community-plugin", ["hosts.read"])).toBe(false);
});
});
describe("manifest gate on the reserved capability", () => {
it("refuses the capability for a plugin that is not first-party", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: [TRANSPORT_OWNER_CAPABILITY] }),
);
expect(parsed).toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(errors[0]).toContain("reserved for first-party plugins");
});
it("refuses it even when mixed in with ordinary permissions", () => {
const { manifest: parsed } = parseManifest(
manifest({
permissions: ["hosts.read", TRANSPORT_OWNER_CAPABILITY, "storage.own"],
}),
);
expect(parsed).toBeUndefined();
});
it("allows it for ssh-terminal", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ssh-terminal");
});
it("allows it for docker", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("docker");
});
it("allows it for host-metrics", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "host-metrics",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("host-metrics");
});
it("allows it for ai", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ai",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ai");
});
it("allows it for proxmox", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "proxmox",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("proxmox");
});
it("allows it for automations", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "automations",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("automations");
});
it("allows it for network-topology", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "network-topology",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("network-topology");
});
it("allows it for tailscale", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "tailscale",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("tailscale");
});
it("allows it for workspaces", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "workspaces",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("workspaces");
});
it("allows it for web-endpoint", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "web-endpoint",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("web-endpoint");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("sample-plugin");
});
});
+11 -13
View File
@@ -1,7 +1,7 @@
/**
* Builds a real unpacked plugin on disk for the loader/broker tests.
* Builds a real unpacked plugin on disk for the runtime tests.
*
* The backend entry has to be a genuine .mjs file the worker can import, and
* The backend entry has to be a genuine .mjs file the loader can import, and
* tsc only emits .ts, so these are written at test time rather than committed.
* Same mkdtemp convention the other disk-touching backend tests use.
*/
@@ -13,12 +13,15 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js";
export interface FixtureOptions {
id?: string;
permissions?: string[];
/** Catalog capability ids the manifest declares. */
capabilities?: string[];
/** Body of backend/index.mjs. Must export activate(ctx). */
backendSource?: string;
/** Omit the backend entry file even though the manifest declares one. */
omitBackendEntry?: boolean;
manifestOverrides?: Record<string, unknown>;
/** Write into this root instead of a fresh temp dir. */
root?: string;
}
export interface Fixture {
@@ -30,13 +33,14 @@ export interface Fixture {
const DEFAULT_BACKEND = `
export async function activate(ctx) {
ctx.__ready = true;
ctx.log.info("fixture activated");
}
`;
export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
const id = options.id ?? "sample-plugin";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const root =
options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "backend"), { recursive: true });
@@ -50,14 +54,8 @@ export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux", "win32", "darwin"],
},
permissions: options.permissions ?? ["hosts.read", "storage.own"],
sidecars: [],
capabilities: options.capabilities ?? ["hosts:read", "kv:own"],
backend: "backend/index.mjs",
...options.manifestOverrides,
};
@@ -1,480 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
grants: [] as { pluginId: string; capability: string }[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.filter((g) => g.pluginId === pluginId),
}),
}));
const { PluginBroker } = await import("../../plugins/broker.js");
const { PluginLoader } = await import("../../plugins/loader.js");
const { buildPluginRouter } = await import("../../plugins/http-bridge.js");
const pluginApi = await import("../../database/routes/plugin-api-routes.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const WORKER_TIMEOUT = 30_000;
describe("plugin HTTP bridge", () => {
const fixtures: Fixture[] = [];
let loader: InstanceType<typeof PluginLoader> | null = null;
let broker: InstanceType<typeof PluginBroker> | null = null;
let server: Server | null = null;
let baseUrl = "";
beforeEach(async () => {
state.grants = [];
const app = express();
app.use("/plugin-api", pluginApi.default);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
broker = null;
for (const id of pluginApi.getRegisteredPluginIds()) {
pluginApi.unregisterPluginRouter(id);
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = null;
while (fixtures.length) fixtures.pop()!.cleanup();
});
async function activate(backendSource: string) {
const fixture = createFixturePlugin({ backendSource });
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
}
it("404s for a plugin that is not running", async () => {
const res = await fetch(`${baseUrl}/plugin-api/ghost/anything`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: "Plugin not installed or not running",
});
});
it(
"dispatches a GET to an active plugin's handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ pong: true });
},
WORKER_TIMEOUT,
);
it(
"passes the body, query and params through to the handler",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("POST", "/echo/:id", async (req) => ({
id: req.params.id,
q: req.query.q,
body: req.body,
method: req.method,
}));
}
`);
const res = await fetch(
`${baseUrl}/plugin-api/sample-plugin/echo/7?q=hello`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "termix" }),
},
);
expect(await res.json()).toEqual({
id: "7",
q: "hello",
body: { name: "termix" },
method: "POST",
});
},
WORKER_TIMEOUT,
);
it(
"honours an explicit status/body envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/missing", async () => ({
status: 404,
body: { error: "nope" },
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/missing`);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "nope" });
},
WORKER_TIMEOUT,
);
it(
"treats a payload with a body field as the body, not an envelope",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/doc", async () => ({
body: "the article text",
title: "A post",
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/doc`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
body: "the article text",
title: "A post",
});
},
WORKER_TIMEOUT,
);
it(
"returns 502 when the plugin handler throws",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/boom", async () => {
throw new Error("handler exploded");
});
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/boom`);
expect(res.status).toBe(502);
expect((await res.json()).detail).toContain("handler exploded");
},
WORKER_TIMEOUT,
);
it(
"does not forward authorization headers or cookies to the plugin",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/headers", async (req) => ({
seen: Object.keys(req.headers).sort(),
}));
}
`);
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/headers`, {
headers: {
authorization: "Bearer super-secret-token",
cookie: "session=secret",
"content-type": "application/json",
},
});
const seen = (await res.json()).seen as string[];
expect(seen).not.toContain("authorization");
expect(seen).not.toContain("cookie");
expect(seen).toContain("content-type");
},
WORKER_TIMEOUT,
);
it(
"rejects an unsupported method and a path without a leading slash",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const errors = [];
try {
await ctx.http.route("TRACE", "/x", () => null);
} catch (e) { errors.push(e.message); }
try {
await ctx.http.route("GET", "no-slash", () => null);
} catch (e) { errors.push(e.message); }
if (errors.length !== 2) throw new Error("expected two rejections");
await ctx.http.route("GET", "/ok", async () => ({ errors }));
}
`,
});
fixtures.push(fixture);
broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "user-1");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ok`);
const errors = (await res.json()).errors as string[];
expect(errors[0]).toContain("does not support the TRACE method");
expect(errors[1]).toContain('must start with "/"');
},
WORKER_TIMEOUT,
);
it(
"404s again once the plugin is deactivated",
async () => {
await activate(`
export async function activate(ctx) {
await ctx.http.route("GET", "/ping", async () => ({ pong: true }));
}
`);
expect(
(await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`)).status,
).toBe(200);
await loader!.deactivate("sample-plugin");
pluginApi.unregisterPluginRouter("sample-plugin");
const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`);
expect(res.status).toBe(404);
},
WORKER_TIMEOUT,
);
describe("per-request actor identity", () => {
/**
* Exercises the real worker end to end: the fixture's activate() calls
* ctx.hosts.list() from inside a genuine ctx.http.route handler, so the
* AsyncLocalStorage context worker-bootstrap.ts enters around that
* invocation is the thing under test, not a mock of it. Two concurrent
* requests as two different users must each see only their own hosts,
* even though both are in flight on the very same worker at once.
*/
async function activateWithHostsRoute() {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/my-hosts", async () => {
const hosts = await ctx.hosts.list();
return { hosts };
});
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
alice: [{ id: 1, name: "alice-host" }],
bob: [{ id: 2, name: "bob-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
onRoutesChanged: (runtime) => {
pluginApi.registerPluginRouter(
runtime.plugin.id,
buildPluginRouter(broker!, runtime),
);
},
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => {
broker!.detach(plugin.id);
pluginApi.unregisterPluginRouter(plugin.id);
},
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
// Activated under "install-owner", a third identity distinct from
// alice and bob, so a test that accidentally fell back to ownerUserId
// instead of the per-request actor would show up as a clear mismatch
// rather than an accidental pass.
await loader.activate("sample-plugin", "install-owner");
}
/**
* Mimics authenticateJWT setting req.userId ahead of the dispatcher, the
* way database.ts really wires it (see plugin-api-routes-auth.test.ts for
* that wiring itself). This file's own app has no auth in front of
* pluginApi.default on purpose, to test the dispatcher in isolation, so
* the per-user identity is injected the same minimal way here.
*/
function fetchAsUser(path: string, userId: string) {
return fetch(`${baseUrl}${path}`, {
headers: { "x-test-user-id": userId },
});
}
it(
"two concurrent requests as different users each see only their own hosts",
async () => {
await activateWithHostsRoute();
// Reopen the server with a stand-in auth middleware that reads the
// test header, since the beforeEach server has none.
await new Promise<void>((resolve) => server?.close(() => resolve()));
const app = express();
app.use((req, _res, next) => {
const userId = req.headers["x-test-user-id"];
if (typeof userId === "string") {
(req as typeof req & { userId?: string }).userId = userId;
}
next();
});
app.use("/plugin-api", pluginApi.default);
server = await new Promise((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const [aliceRes, bobRes] = await Promise.all([
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"),
fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"),
]);
expect(await aliceRes.json()).toMatchObject({
hosts: [{ id: 1, name: "alice-host" }],
});
expect(await bobRes.json()).toMatchObject({
hosts: [{ id: 2, name: "bob-host" }],
});
},
WORKER_TIMEOUT,
);
it(
"falls back to the install owner when a call has no inbound request",
async () => {
const fixture = createFixturePlugin({
backendSource: `
export async function activate(ctx) {
const hosts = await ctx.hosts.list();
await ctx.log.info(JSON.stringify({ hosts }));
}
`,
});
fixtures.push(fixture);
const hostsByUser: Record<string, { id: number; name: string }[]> = {
"install-owner": [{ id: 9, name: "owner-host" }],
};
broker = new PluginBroker({
listHosts: async (userId) => hostsByUser[userId] ?? [],
resolveHost: async () => null,
});
const logged: string[] = [];
const { pluginLogger } = await import("../../utils/logger.js");
vi.mocked(pluginLogger.info).mockImplementation((message: string) => {
logged.push(message);
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker),
onWorkerGone: (plugin) => broker!.detach(plugin.id),
});
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts.read",
});
invalidatePluginPermissionCache("sample-plugin");
await loader.load(fixture.dir);
await loader.activate("sample-plugin", "install-owner");
const line = logged.find((entry) => entry.startsWith("{"));
expect(line && JSON.parse(line)).toMatchObject({
hosts: [{ id: 9, name: "owner-host" }],
});
},
WORKER_TIMEOUT,
);
});
});
@@ -1,200 +0,0 @@
/**
* The in-process branch of the loader: a first-party plugin runs on the main
* thread, with no worker, and its deactivate() actually gets awaited.
*
* These use the real ssh-terminal id because the allowlist is hardcoded and
* that is the point -- there is deliberately no way to fake membership.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry, consume } from "../../plugins/registry.js";
import { pluginEvents } from "../../plugins/events.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const FIRST_PARTY_OPTIONS = {
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
};
/**
* Writes a marker file rather than using a module-level variable: the plugin
* is imported by URL into the same process, but ESM caching means a second
* fixture at a different path is a different module instance.
*/
function backendWritingMarker(markerPath: string, extra = ""): string {
const literal = JSON.stringify(markerPath);
return `
import fs from "node:fs";
export async function activate(ctx) {
fs.writeFileSync(${literal}, "activated");
${extra}
}
export async function deactivate() {
fs.appendFileSync(${literal}, ":deactivated");
}
`;
}
describe("in-process first-party plugins", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
beforeEach(() => {
clearRegistry();
pluginEvents.clear();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
pluginEvents.clear();
});
it("activates on the main thread with no worker and no owner", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
const onWorkerReady = vi.fn();
loader = new PluginLoader({ onWorkerReady });
const plugin = await loader.load(fixture.dir);
// No ownerUserId: an in-process plugin never acts as a user.
await loader.activate(plugin.id);
expect(plugin.state).toBe("active");
expect(plugin.worker).toBeNull();
expect(plugin.inProcess).not.toBeNull();
expect(plugin.ownerUserId).toBeNull();
// The broker attaches via this hook; an in-process plugin bypasses it.
expect(onWorkerReady).not.toHaveBeenCalled();
expect(fs.readFileSync(marker, "utf8")).toBe("activated");
});
it("awaits deactivate() on disable and clears the handle", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.inProcess).toBeNull();
expect(fs.readFileSync(marker, "utf8")).toBe("activated:deactivated");
});
it("revokes registry entries the plugin provided", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(
marker,
`ctx.registry.provide("terminal.sessions", { live: true });`,
),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(consume("terminal.sessions")).toEqual({ live: true });
await loader.deactivate(plugin.id);
// Disabling the plugin must take its services with it, or a consumer
// would keep resolving a dead provider.
expect(consume("terminal.sessions")).toBeUndefined();
});
it("detaches event listeners the plugin registered", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker, `ctx.events.on("host.status", () => {});`),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
await loader.deactivate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(0);
});
it("reports an activation failure instead of leaving a half-live plugin", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export async function activate() { throw new Error("boom"); }`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow("boom");
expect(plugin.state).toBe("crashed");
expect(plugin.inProcess).toBeNull();
expect(plugin.lastError).toContain("boom");
});
it("rejects an entry with no activate export", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export const nothing = true;`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow(
/does not export an activate/,
);
});
it("runs a first-party plugin in a worker when it does not ask for the tier", async () => {
fixture = createFixturePlugin({
id: "ssh-terminal",
permissions: ["hosts.read"],
manifestOverrides: { category: "Terminal" },
});
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
expect(plugin.state).toBe("active");
expect(plugin.worker).not.toBeNull();
expect(plugin.inProcess).toBeNull();
});
});
+469 -320
View File
@@ -1,350 +1,499 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
/**
* The single-tier loader: discovery, dependency order and lifecycle.
*
* These drive the real PluginLoader against real plugin directories written
* to a temp dir, because the things worth testing here are exactly the ones a
* mock would paper over: does a manifest on disk parse, does an id collision
* get refused, does a plugin's own deactivate actually run.
*/
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
}),
}));
// Worker spawn plus crash backoff does not fit the 5s backend default.
const WORKER_TIMEOUT = 30_000;
const cleanups: Array<() => void> = [];
describe("PluginLoader", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function fixture(...args: Parameters<typeof createFixturePlugin>) {
const created = createFixturePlugin(...args);
fixtures.push(created);
return created;
}
/** Points the loader at `bundled` and `user` as its two discovery roots. */
function setRoots(bundled: string, user: string): void {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
}
afterEach(async () => {
await loader?.shutdown();
loader = null;
while (fixtures.length) fixtures.pop()!.cleanup();
beforeEach(() => {
vi.unstubAllEnvs();
});
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
describe("PluginLoader discovery", () => {
it("loads a valid plugin directory", async () => {
const fixture = createFixturePlugin();
cleanups.push(fixture.cleanup);
const loader = new PluginLoader();
const plugin = await loader.load(fixture.dir, "bundled");
expect(plugin.id).toBe("sample-plugin");
expect(plugin.state).toBe("loaded");
expect(plugin.manifest.capabilities).toEqual(["hosts:read", "kv:own"]);
});
describe("load", () => {
it("loads a valid plugin directory", async () => {
const plugin = fixture();
loader = new PluginLoader();
const loaded = await loader.load(plugin.dir);
expect(loaded.id).toBe("sample-plugin");
expect(loaded.state).toBe("loaded");
expect(loaded.worker).toBeNull();
expect(loader.get("sample-plugin")).toBe(loaded);
it("rejects a directory whose name does not match the manifest id", async () => {
const fixture = createFixturePlugin({
manifestOverrides: { id: "something-else" },
});
cleanups.push(fixture.cleanup);
it("rejects a manifest that fails validation", async () => {
const plugin = fixture({ manifestOverrides: { version: "nope" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/Invalid plugin manifest/,
);
});
it("rejects a directory whose name does not match the manifest id", async () => {
const plugin = fixture({ manifestOverrides: { id: "other-id" } });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/does not match manifest id/,
);
});
it("rejects a declared backend with no entry file", async () => {
const plugin = fixture({ omitBackendEntry: true });
loader = new PluginLoader();
await expect(loader.load(plugin.dir)).rejects.toThrow(
/declares a backend but/,
);
});
it("rejects an unreadable manifest", async () => {
loader = new PluginLoader();
await expect(loader.load("/nonexistent/plugin-dir")).rejects.toThrow(
/Could not read plugin manifest/,
);
});
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/does not match manifest id/,
);
});
describe("activate and deactivate", () => {
it(
"activates a plugin and runs its activate(ctx)",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
it("rejects a manifest whose backend entry is missing", async () => {
const fixture = createFixturePlugin({ omitBackendEntry: true });
cleanups.push(fixture.cleanup);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("active");
expect(loaded.worker).not.toBeNull();
expect(loaded.ownerUserId).toBe("user-1");
},
WORKER_TIMEOUT,
const loader = new PluginLoader();
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
/backend entry .* is missing/,
);
it(
"surfaces an error thrown inside activate(ctx)",
async () => {
const plugin = fixture({
backendSource: `
export async function activate() {
throw new Error("boom from plugin");
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/boom from plugin/);
},
WORKER_TIMEOUT,
);
it(
"rejects a backend entry with no activate export",
async () => {
const plugin = fixture({
backendSource: `export const nothing = true;`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(
loader.activate("sample-plugin", "user-1"),
).rejects.toThrow(/does not export an activate\(ctx\) function/);
},
WORKER_TIMEOUT,
);
it(
"deactivate terminates the worker and does not count as a crash",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
await loader.deactivate("sample-plugin");
const loaded = loader.get("sample-plugin")!;
expect(loaded.state).toBe("stopped");
expect(loaded.worker).toBeNull();
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"does not leak the server's environment into the worker",
async () => {
process.env.TERMIX_TEST_SECRET = "super-secret-value";
const plugin = fixture({
backendSource: `
export async function activate() {
if (process.env.TERMIX_TEST_SECRET) {
throw new Error("leaked:" + process.env.TERMIX_TEST_SECRET);
}
}
`,
});
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
expect(loader.get("sample-plugin")!.state).toBe("active");
delete process.env.TERMIX_TEST_SECRET;
},
WORKER_TIMEOUT,
);
it("deactivating a never-started plugin is a no-op", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")!.state).toBe("stopped");
});
it("activating an unknown plugin throws", async () => {
loader = new PluginLoader();
await expect(loader.activate("ghost", "user-1")).rejects.toThrow(
/Plugin ghost is not loaded/,
);
});
});
describe("crash restart", () => {
it(
"restarts a crashed plugin, then disables it after the retry budget",
async () => {
// Exits on a timer so activation succeeds first, then the worker dies.
const plugin = fixture({
backendSource: `
export async function activate() {
setTimeout(() => process.exit(1), 10);
}
`,
});
it("skips an invalid directory and loads the rest", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
loader = new PluginLoader({ restartBackoffMs: [10, 10, 10] });
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
createFixturePlugin({ id: "good-plugin", root: bundled });
const broken = path.join(bundled, "broken-plugin");
fs.mkdirSync(broken, { recursive: true });
fs.writeFileSync(path.join(broken, "manifest.json"), "{ not json");
const loaded = loader.get("sample-plugin")!;
await vi.waitFor(
() => {
expect(loaded.state).toBe("disabled");
},
{ timeout: 25_000, interval: 50 },
);
setRoots(bundled, user);
expect(loaded.restartAttempts).toBeGreaterThan(MAX_RESTART_ATTEMPTS);
expect(loaded.worker).toBeNull();
},
WORKER_TIMEOUT,
);
const loader = new PluginLoader();
const loaded = await loader.loadAll();
it(
"retry clears the crash counter and starts the plugin again",
async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await loader.activate("sample-plugin", "user-1");
const loaded = loader.get("sample-plugin")!;
// Simulate having been auto-disabled.
await loader.deactivate("sample-plugin");
loaded.state = "disabled";
loaded.restartAttempts = MAX_RESTART_ATTEMPTS + 1;
await loader.retry("sample-plugin");
expect(loaded.state).toBe("active");
// retry() clears the counter up front, before the stability window.
expect(loaded.restartAttempts).toBe(0);
},
WORKER_TIMEOUT,
);
it(
"clears the crash counter once a plugin stays up past the stability window",
async () => {
const plugin = fixture();
loader = new PluginLoader({ stabilityWindowMs: 50 });
await loader.load(plugin.dir);
const loaded = loader.get("sample-plugin")!;
loaded.restartAttempts = 2;
await loader.activate("sample-plugin", "user-1");
await vi.waitFor(
() => {
expect(loaded.restartAttempts).toBe(0);
},
{ timeout: 5_000, interval: 25 },
);
},
WORKER_TIMEOUT,
);
it("retry on a plugin that never activated throws", async () => {
const plugin = fixture();
loader = new PluginLoader();
await loader.load(plugin.dir);
await expect(loader.retry("sample-plugin")).rejects.toThrow(
/never been activated/,
);
});
expect(loaded.map((plugin) => plugin.id)).toEqual(["good-plugin"]);
});
describe("loadAll", () => {
// loadAll scans bundled plugins as well as user-installed ones, and the
// repo really does ship one now. These tests are about the user directory,
// so they point the bundled dir somewhere empty.
let previousBundled: string | undefined;
// A plugin dropped into the data directory must never be able to take over
// an id the install already ships, or "ssh-terminal" becomes whatever the
// last person to write that folder wanted it to be.
it("refuses a user plugin that shadows a bundled id", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
const userPlugins = path.join(user, "plugins");
fs.mkdirSync(userPlugins, { recursive: true });
beforeEach(() => {
previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins";
createFixturePlugin({ id: "ssh-terminal", root: bundled });
createFixturePlugin({
id: "ssh-terminal",
root: userPlugins,
manifestOverrides: { name: "Impostor" },
});
afterEach(() => {
if (previousBundled === undefined) {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
} else {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled;
}
});
setRoots(bundled, user);
it("returns an empty list when the plugins dir does not exist", async () => {
const previous = process.env.DATA_DIR;
process.env.DATA_DIR = "/nonexistent/data-dir";
loader = new PluginLoader();
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(await loader.loadAll()).toEqual([]);
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
});
it("skips invalid directories and loads the rest", async () => {
const good = fixture({ id: "good-plugin" });
// Share the same root so both sit under one plugins dir.
const bad = createFixturePlugin({
id: "bad-plugin",
manifestOverrides: { version: "nope" },
});
fixtures.push(bad);
const fs = await import("node:fs");
const path = await import("node:path");
fs.cpSync(bad.dir, path.join(good.root, "bad-plugin"), {
recursive: true,
});
const previous = process.env.DATA_DIR;
// getPluginsDir() appends "plugins", so point DATA_DIR at the parent.
const dataDir = path.join(good.root, "..");
const pluginsDir = path.join(dataDir, "plugins");
fs.mkdirSync(pluginsDir, { recursive: true });
fs.cpSync(good.dir, path.join(pluginsDir, "good-plugin"), {
recursive: true,
});
fs.cpSync(bad.dir, path.join(pluginsDir, "bad-plugin"), {
recursive: true,
});
process.env.DATA_DIR = dataDir;
loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((p) => p.id)).toEqual(["good-plugin"]);
fs.rmSync(pluginsDir, { recursive: true, force: true });
if (previous === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previous;
expect(loaded).toHaveLength(1);
expect(loaded[0].source).toBe("bundled");
expect(loaded[0].manifest.name).toBe("Sample Plugin");
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
for (const spec of specs) {
createFixturePlugin({ ...spec, root: bundled });
}
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("orders a dependency before its dependent", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { order, blocked, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(cycles.size).toBe(0);
expect(blocked.size).toBe(0);
expect(order.indexOf("beta")).toBeLessThan(order.indexOf("alpha"));
});
it("blocks a plugin whose hard dependency is not installed", async () => {
const loader = await loadAll([
{
id: "alpha",
manifestOverrides: { dependencies: { missing: "^1.0.0" } },
},
]);
const { order, blocked } = loader.resolveOrder(["alpha"]);
expect(order).toEqual([]);
expect(blocked.get("alpha")).toMatch(/not installed/);
});
it("blocks a plugin whose dependency is present at the wrong version", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^2.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha", "beta"]);
expect(blocked.get("alpha")).toMatch(/1\.0\.0 is installed/);
});
it("blocks a plugin whose dependency is installed but disabled", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta" },
]);
const { blocked } = loader.resolveOrder(["alpha"]);
expect(blocked.get("alpha")).toMatch(/not enabled/);
});
// Nothing to wait for, so this is a failure rather than a block.
it("fails every plugin in a dependency cycle", async () => {
const loader = await loadAll([
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
{ id: "beta", manifestOverrides: { dependencies: { alpha: "^1.0.0" } } },
]);
const { order, cycles } = loader.resolveOrder(["alpha", "beta"]);
expect(order).toEqual([]);
expect(cycles.get("alpha")).toMatch(/cycle/);
expect(cycles.get("beta")).toMatch(/cycle/);
});
it("activates in dependency order and reports blocked plugins", async () => {
const marker = path.join(tempRoot("termix-order-"), "order.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
createFixturePlugin({
id: "gamma",
root: bundled,
manifestOverrides: { dependencies: { nowhere: "^1.0.0" } },
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const result = await loader.activateAll(["alpha", "beta", "gamma"]);
expect(result.activated).toEqual(["beta", "alpha"]);
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"beta",
"alpha",
]);
expect(loader.get("gamma")?.state).toBe("blocked");
expect(loader.get("gamma")?.lastError).toMatch(/not installed/);
});
});
describe("PluginLoader lifecycle", () => {
async function single(
backendSource: string,
id = "sample-plugin",
): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({ id, root: bundled, backendSource });
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
return loader;
}
it("runs activate and then deactivate", async () => {
const marker = path.join(tempRoot("termix-life-"), "life.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate() {
fs.appendFileSync(${JSON.stringify(marker)}, "up\\n");
}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "down\\n");
}
`);
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("up\ndown\n");
});
it("marks a plugin failed when activate throws", async () => {
const loader = await single(`
export async function activate() {
throw new Error("no good");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("no good");
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("no good");
});
it("rejects a backend entry with no activate export", async () => {
const loader = await single(`export const nothing = true;`);
await expect(loader.activate("sample-plugin")).rejects.toThrow(
/does not export an activate/,
);
});
// The whole point of the bag: cleanup cannot be conditional on the plugin
// behaving well on the way out.
it("disposes everything even when deactivate throws", async () => {
const marker = path.join(tempRoot("termix-dispose-"), "disposed.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "disposed\\n");
});
}
export async function deactivate() {
throw new Error("deactivate blew up");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("disposed\n");
});
// ESM caches the module, so the second activate runs against the same
// module object the first one did. Anything created at module scope would
// already be torn down by then.
it("supports enable, disable, enable without a restart", async () => {
const marker = path.join(tempRoot("termix-cycle-"), "cycle.txt");
const loader = await single(`
import fs from "node:fs";
let timer = null;
let server = null;
export async function activate(ctx) {
server = { closed: false };
timer = setInterval(() => {}, 1000);
ctx.disposables.add(() => {
clearInterval(timer);
server.closed = true;
fs.appendFileSync(${JSON.stringify(marker)}, "closed\\n");
});
fs.appendFileSync(${JSON.stringify(marker)}, "started\\n");
}
`);
await loader.activate("sample-plugin");
await loader.deactivate("sample-plugin");
await loader.activate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(fs.readFileSync(marker, "utf8")).toBe("started\nclosed\nstarted\n");
await loader.deactivate("sample-plugin");
});
it("disposes registrations made before activate threw", async () => {
const marker = path.join(tempRoot("termix-partial-"), "partial.txt");
const loader = await single(`
import fs from "node:fs";
export async function activate(ctx) {
ctx.disposables.add(() => {
fs.appendFileSync(${JSON.stringify(marker)}, "cleaned\\n");
});
throw new Error("halfway");
}
`);
await expect(loader.activate("sample-plugin")).rejects.toThrow("halfway");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned\n");
});
it("deactivating a plugin that never started is a no-op", async () => {
const loader = await single(`export async function activate() {}`);
await loader.deactivate("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("stopped");
});
it("shuts plugins down in reverse activation order", async () => {
const marker = path.join(tempRoot("termix-shutdown-"), "shutdown.txt");
const record = (id: string) => `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
}
`;
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: record("alpha"),
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: record("beta"),
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
// beta started first, so it stops last: a dependency outlives its
// dependents.
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
"alpha",
"beta",
]);
});
});
describe("PluginLoader error budget", () => {
async function failing(threshold: number): Promise<PluginLoader> {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({
errorThreshold: threshold,
errorWindowMs: 60_000,
});
await loader.loadAll();
await loader.activate("sample-plugin");
return loader;
}
it("marks a plugin failed once it trips the threshold", async () => {
const loader = await failing(3);
await loader.reportError("sample-plugin", new Error("one"));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
await loader.reportError("sample-plugin", new Error("three"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
expect(loader.get("sample-plugin")?.lastError).toBe("three");
});
it("forgets errors that fall outside the window", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
root: bundled,
backendSource: `export async function activate() {}`,
});
setRoots(bundled, user);
const loader = new PluginLoader({ errorThreshold: 2, errorWindowMs: 10 });
await loader.loadAll();
await loader.activate("sample-plugin");
await loader.reportError("sample-plugin", new Error("one"));
await new Promise((resolve) => setTimeout(resolve, 25));
await loader.reportError("sample-plugin", new Error("two"));
expect(loader.get("sample-plugin")?.state).toBe("active");
});
it("wrap() counts a throwing callback instead of letting it escape", async () => {
const loader = await failing(2);
const wrapped = loader.wrap("sample-plugin", () => {
throw new Error("callback failed");
});
await expect(wrapped()).resolves.toBeUndefined();
await wrapped();
expect(loader.get("sample-plugin")?.state).toBe("failed");
});
it("retry clears the budget and starts the plugin again", async () => {
const loader = await failing(1);
await loader.reportError("sample-plugin", new Error("down"));
expect(loader.get("sample-plugin")?.state).toBe("failed");
await loader.retry("sample-plugin");
expect(loader.get("sample-plugin")?.state).toBe("active");
expect(loader.get("sample-plugin")?.lastError).toBeNull();
});
});
+394 -438
View File
@@ -1,3 +1,11 @@
/**
* Manifest v2 validation.
*
* The rules live in the SDK so the server, the authoring script and plugin
* authors share one implementation; these tests drive it through the server's
* re-export, which is the path the loader actually takes.
*/
import { describe, expect, it } from "vitest";
import {
parseManifest,
@@ -5,484 +13,432 @@ import {
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function validManifest(overrides: Record<string, unknown> = {}) {
function base(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A sample plugin.",
author: { name: "Jane Doe" },
description: "A fixture.",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: ["hosts.read", "storage.own"],
sidecars: [],
capabilities: ["kv:own"],
...overrides,
};
}
describe("plugin manifest validation", () => {
it("accepts a valid manifest", () => {
expect(validateManifest(validManifest())).toEqual([]);
describe("manifest v2 validation", () => {
it("accepts a minimal valid manifest", () => {
expect(validateManifest(base())).toEqual([]);
});
it("reports every missing required field", () => {
const errors = validateManifest({ id: "x" });
expect(errors).toContain('Missing required field: "description"');
expect(errors).toContain('Missing required field: "engine"');
expect(errors).toContain('Missing required field: "sidecars"');
it("reports every missing required field at once", () => {
const errors = validateManifest({ id: "sample-plugin" });
expect(errors.some((e) => e.includes('"name"'))).toBe(true);
expect(errors.some((e) => e.includes('"version"'))).toBe(true);
expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true);
});
it("rejects a bad id pattern", () => {
const errors = validateManifest(validManifest({ id: "Bad_Id" }));
expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe(
true,
it("rejects an id that is not a lowercase slug", () => {
expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects an id that starts with a digit", () => {
expect(validateManifest(base({ id: "1plugin" })).join()).toMatch(
/"id" must match/,
);
});
it("rejects a non-semver version", () => {
const errors = validateManifest(validManifest({ version: "1.0" }));
expect(errors).toContain(
'Field "version" must be valid semver, got: "1.0"',
expect(validateManifest(base({ version: "1.0" })).join()).toMatch(
/valid semver/,
);
});
describe("provides / requires", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.greet.use"],
};
it("accepts a declared service and requirement", () => {
const errors = validateManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.greet.use",
},
],
requires: [
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
],
contributes: { permissionGroup },
}),
);
expect(errors).toEqual([]);
});
it("rejects a service name that is not dotted", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
});
it("rejects a non-semver service version", () => {
const errors = validateManifest(
validManifest({
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
}),
);
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
});
it("rejects a duplicate provided service", () => {
const errors = validateManifest(
validManifest({
provides: [
{ service: "a.b", version: "1.0.0", permission: "x.y" },
{ service: "a.b", version: "2.0.0", permission: "x.y" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("rejects an invalid semver range", () => {
const errors = validateManifest(
validManifest({
requires: [{ service: "a.b", versionRange: "not a range" }],
}),
);
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
});
it("rejects a non-boolean optional", () => {
const errors = validateManifest(
validManifest({
requires: [
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
],
}),
);
expect(errors.some((e) => e.includes("optional"))).toBe(true);
});
it("refuses a service gated by a permission the group never declares", () => {
// Otherwise every call through it would deny against a permission no
// admin could ever grant.
const { manifest, errors } = parseManifest(
validManifest({
provides: [
{
service: "testplugin.greet",
version: "1.0.0",
permission: "testplugin.undeclared",
},
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
});
describe("providesSecret / requiresSecret", () => {
const permissionGroup = {
group: "testplugin",
permissions: ["testplugin.secrets.share"],
};
it("accepts a well-formed pair", () => {
expect(
validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
],
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key", optional: true },
],
contributes: { permissionGroup },
}),
),
).toEqual([]);
});
it("rejects a dotted secret key", () => {
// The owning plugin is already its own field, so a dotted key would only
// encode the owner twice and invite the two disagreeing.
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "ai.api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("providesSecret[0].key"))).toBe(
true,
);
});
it("rejects a duplicate offer of the same key", () => {
const errors = validateManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.secrets.share" },
{ key: "api-key", permission: "testplugin.secrets.share" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("requires a permission on an offer", () => {
const errors = validateManifest(
validManifest({ providesSecret: [{ key: "api-key" }] }),
);
expect(
errors.some((e) => e.includes("providesSecret[0].permission")),
).toBe(true);
});
it("rejects a reference with a bad plugin id", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [{ plugin: "Bad_Id", key: "api-key" }],
}),
);
expect(errors.some((e) => e.includes("requiresSecret[0].plugin"))).toBe(
true,
);
});
it("rejects a duplicate reference to the same plugin and key", () => {
const errors = validateManifest(
validManifest({
requiresSecret: [
{ plugin: "ai-assistant", key: "api-key" },
{ plugin: "ai-assistant", key: "api-key" },
],
}),
);
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
});
it("refuses a shared secret gated by a permission the group never declares", () => {
// Same rule as a service: sharing rides on an ordinary role permission,
// so it has to be one an admin can actually grant.
const { manifest, errors } = parseManifest(
validManifest({
providesSecret: [
{ key: "api-key", permission: "testplugin.undeclared" },
],
contributes: { permissionGroup },
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("testplugin.undeclared");
expect(errors[0]).toContain("permissionGroup");
});
it("refuses a plugin borrowing from itself", () => {
const { manifest, errors } = parseManifest(
validManifest({
requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }],
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain("names this plugin itself");
});
});
it("rejects an unknown permission", () => {
const errors = validateManifest(
validManifest({ permissions: ["credentials.read"] }),
);
expect(
errors.some((e) => e.includes('Unknown permission: "credentials.read"')),
).toBe(true);
});
it("rejects an unknown category", () => {
const errors = validateManifest(validManifest({ category: "Games" }));
expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch(
/must be one of/,
);
});
// Capabilities were dotted in v1. They are colon-separated now so they can
// never be confused with the dotted RBAC permissions.
it("rejects a dotted capability name", () => {
expect(
errors.some((e) => e.startsWith('Field "category" must be one of')),
).toBe(true);
validateManifest(base({ capabilities: ["hosts.read"] })).join(),
).toMatch(/not a known capability/);
});
it("rejects a non-object manifest", () => {
expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]);
expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]);
it("rejects a capability outside the catalog", () => {
expect(
validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(),
).toMatch(/not a known capability/);
});
it("validates contributes sub-shapes", () => {
it("rejects a duplicated capability", () => {
expect(
validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(),
).toMatch(/duplicates/);
});
it("refuses a manifest targeting another SDK api version", () => {
const { manifest, errors } = parseManifest(
base({ engine: { termix: ">=2.9.0", api: "2" } }),
);
expect(manifest).toBeUndefined();
expect(errors.join()).toMatch(/SDK API version/);
});
it("fills in the entry point defaults", () => {
const { manifest } = parseManifest(base());
expect(manifest?.backend).toBe("dist/backend.js");
expect(manifest?.frontend).toBe("dist/frontend.js");
expect(manifest?.locales).toBe("locales");
});
});
// v1 accepted unknown fields silently, so a typo'd "contribute" validated
// clean and was then dropped. A manifest has to mean what it says.
describe("unknown field rejection", () => {
it("rejects an unknown top-level field", () => {
expect(validateManifest(base({ sudoEverything: true })).join()).toMatch(
/Unknown field "sudoEverything"/,
);
});
it("rejects a near-miss of a real field name", () => {
expect(validateManifest(base({ contribute: {} })).join()).toMatch(
/Unknown field "contribute"/,
);
});
it("rejects an unknown field inside contributes", () => {
const errors = validateManifest(
validManifest({
base({ contributes: { dashboardCards: [] } }),
);
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
});
it("rejects an unknown field inside a tab", () => {
const errors = validateManifest(
base({
contributes: {
tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }],
apiPrefix: "Bad Prefix",
tabs: [
{
id: "t",
titleKey: "k",
icon: "i",
openFrom: ["rail"],
surprise: 1,
},
],
},
}),
);
expect(
errors.some((e) => e.includes('openFrom has unknown value: "nope"')),
).toBe(true);
expect(
errors.some((e) => e.includes('"contributes.apiPrefix" must match')),
).toBe(true);
expect(errors.join()).toMatch(/Unknown field "surprise"/);
});
describe("actions / actionSlots", () => {
function withActions(contributes: Record<string, unknown>) {
return validateManifest(validManifest({ contributes }));
}
it("rejects an unknown field inside author and engine", () => {
expect(
validateManifest(base({ author: { name: "x", role: "admin" } })).join(),
).toMatch(/Unknown field "role"/);
it("accepts a valid actions and actionSlots pair", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "ai.assistant",
handler: "openWithContext",
icon: "Bot",
slot: "terminal.toolbar",
kind: "button",
},
],
actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }],
});
expect(errors).toEqual([]);
});
it("allows a camelCase segment in an action id", () => {
const errors = withActions({
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "openWithContext",
},
],
});
expect(errors).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
const errors = withActions({
actions: [{ id: "notdotted", titleKey: "k", handler: "h" }],
});
expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe(
true,
);
});
it("rejects a duplicate action id", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h" },
{ id: "a.one", titleKey: "k", handler: "h" },
],
});
expect(
errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')),
).toBe(true);
});
it("requires titleKey and a valid handler", () => {
const errors = withActions({
actions: [{ id: "a.one", handler: "not a identifier" }],
});
expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true);
expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true);
});
it("rejects an unknown contribution kind", () => {
const errors = withActions({
actions: [
{ id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" },
],
});
expect(
errors.some((e) => e.includes("actions[0].kind must be one of")),
).toBe(true);
});
it("rejects an unknown value in a slot's accepts", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }],
});
expect(
errors.some((e) =>
e.includes('actionSlots[0].accepts has unknown value: "menu"'),
),
).toBe(true);
});
it("rejects an empty accepts array", () => {
const errors = withActions({
actionSlots: [{ id: "terminal.toolbar", accepts: [] }],
});
expect(
errors.some((e) =>
e.includes("actionSlots[0].accepts must be a non-empty array"),
),
).toBe(true);
});
it("rejects a duplicate slot id", () => {
const errors = withActions({
actionSlots: [
{ id: "terminal.toolbar", accepts: ["button"] },
{ id: "terminal.toolbar", accepts: ["button"] },
],
});
expect(
errors.some((e) => e.includes("actionSlots[1].id is a duplicate")),
).toBe(true);
});
expect(
validateManifest(
base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }),
).join(),
).toMatch(/Unknown field "unsafe"/);
});
describe("parseManifest", () => {
it("refuses an action gated by an undeclared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: { group: "ai", permissions: ["ai.use"] },
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
it("rejects an unknown field inside a provides entry", () => {
const errors = validateManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.use",
elevated: true,
},
}),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain(
'Action "ai.openWithContext" is gated by "ai.services.use"',
);
});
it("allows an action gated by a declared permission", () => {
const { manifest, errors } = parseManifest(
validManifest({
contributes: {
permissionGroup: {
group: "ai",
permissions: ["ai.services.use"],
},
actions: [
{
id: "ai.openWithContext",
titleKey: "k",
handler: "h",
permission: "ai.services.use",
},
],
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext");
});
},
}),
);
it("allows an ungated action with no permissionGroup at all", () => {
const { errors } = parseManifest(
validManifest({
contributes: {
actions: [{ id: "a.one", titleKey: "k", handler: "h" }],
},
}),
);
expect(errors).toEqual([]);
});
it("narrows a valid manifest", () => {
const { manifest, errors } = parseManifest(validManifest());
expect(errors).toEqual([]);
expect(manifest?.id).toBe("sample-plugin");
});
it("refuses a manifest targeting a different SDK major", () => {
const { manifest, errors } = parseManifest(
validManifest({ engine: { termix: ">=2.9.0", api: "99" } }),
);
expect(manifest).toBeUndefined();
expect(errors[0]).toContain('targets SDK api version "99"');
});
it("does not narrow when validation failed", () => {
const { manifest, errors } = parseManifest({ id: "x" });
expect(manifest).toBeUndefined();
expect(errors.length).toBeGreaterThan(0);
});
expect(errors.join()).toMatch(/Unknown field "elevated"/);
});
});
describe("dependencies", () => {
it("accepts a well-formed dependency map", () => {
expect(
validateManifest(base({ dependencies: { automations: "^1.0.0" } })),
).toEqual([]);
});
it("rejects a range that is not valid semver", () => {
expect(
validateManifest(
base({ dependencies: { automations: "latest!" } }),
).join(),
).toMatch(/valid semver range/);
});
it("refuses a plugin depending on itself", () => {
const { errors } = parseManifest(
base({ dependencies: { "sample-plugin": "^1.0.0" } }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses the same plugin in both dependency maps", () => {
const { errors } = parseManifest(
base({
dependencies: { automations: "^1.0.0" },
optionalDependencies: { automations: "^1.0.0" },
}),
);
expect(errors.join()).toMatch(/both dependencies and optionalDependencies/);
});
});
describe("permission groups", () => {
it("accepts a group and its declared permissions", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
),
).toEqual([]);
});
// Without this a manifest could hand any role admin.* at boot.
it("refuses a role default outside the plugin's own permissions", () => {
const errors = validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { user: ["admin.users.manage"] },
},
},
}),
);
expect(errors.join()).toMatch(/does not declare/);
});
it("accepts a role default the plugin does declare", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { admin: ["sample-plugin.use"] },
},
},
}),
),
).toEqual([]);
});
});
describe("services, secrets and actions", () => {
it("refuses a service gated by a permission the group never declares", () => {
const { errors } = parseManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.missing",
},
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a shared secret gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
providesSecret: [
{ key: "api-key", permission: "sample-plugin.missing" },
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("refuses a plugin borrowing a secret from itself", () => {
const { errors } = parseManifest(
base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }),
);
expect(errors.join()).toMatch(/points at this plugin/);
});
it("refuses an action gated by an undeclared permission", () => {
const { errors } = parseManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
actions: [
{
id: "sample.open",
titleKey: "k",
handler: "open",
permission: "sample-plugin.missing",
},
],
},
}),
);
expect(errors.join()).toMatch(/not declared in contributes/);
});
it("allows a camelCase segment in an action id", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "ai.openWithContext", titleKey: "k", handler: "open" },
],
},
}),
),
).toEqual([]);
});
it("rejects an action id that is not dotted", () => {
expect(
validateManifest(
base({
contributes: {
actions: [{ id: "open", titleKey: "k", handler: "open" }],
},
}),
).join(),
).toMatch(/dotted action id/);
});
it("rejects a handler that is not an identifier", () => {
expect(
validateManifest(
base({
contributes: {
actions: [
{ id: "sample.open", titleKey: "k", handler: "not valid!" },
],
},
}),
).join(),
).toMatch(/JavaScript identifier/);
});
});
describe("tabs and host capabilities", () => {
it("rejects an unknown openFrom value", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] },
],
},
}),
).join(),
).toMatch(/openFrom\[0\] must be one of/);
});
it("rejects duplicate tab ids", () => {
expect(
validateManifest(
base({
contributes: {
tabs: [
{ id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] },
{ id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] },
],
},
}),
).join(),
).toMatch(/duplicates/);
});
it("accepts hostCapability as a single object or an array", () => {
const one = {
key: "enableThing",
labelKey: "k",
editorTab: "general",
};
expect(
validateManifest(base({ contributes: { hostCapability: one } })),
).toEqual([]);
expect(
validateManifest(
base({
contributes: {
hostCapability: [one, { ...one, key: "enableOther" }],
},
}),
),
).toEqual([]);
});
it("rejects duplicate hostCapability keys", () => {
const one = { key: "enableThing", labelKey: "k", editorTab: "general" };
expect(
validateManifest(
base({ contributes: { hostCapability: [one, one] } }),
).join(),
).toMatch(/duplicates/);
});
});
@@ -2,11 +2,9 @@
* Two real plugins on disk, one offering a secret and one borrowing it, driven
* through the actual PluginLoader.
*
* Same borrowed-id trick as service-contract.test.ts: the in-process allowlist
* is hardcoded and deliberately unfakeable, so the fixtures use real
* first-party ids to reach that tier. The secret ("api-key") and the
* permission gating it ("testplugin.secrets.share") are fixture-owned, so
* nothing about the shipped ssh-terminal or docker plugins is under test.
* The secret ("api-key") and the permission gating it
* ("testplugin.secrets.share") are fixture-owned, so nothing about the
* shipped plugins is under test even though the fixtures borrow their ids.
*
* What the unit test cannot show, and this does: that the manifest declaration
* is enforced by the real ctx, and that a genuine uninstall through
@@ -53,8 +51,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearSecretRegistry,
getSecretRegistration,
@@ -107,7 +103,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
providesSecret: [{ key: KEY, permission: PERMISSION }],
@@ -125,7 +121,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: CONSUMER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requiresSecret },
});
@@ -286,7 +282,7 @@ describe("cross-plugin secret references", () => {
it("refuses to offer a secret the provider's manifest never declared", async () => {
provider = createFixturePlugin({
id: PROVIDER_ID,
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.secrets.offer("undeclared-key", () => "nope");
@@ -2,12 +2,9 @@
* Two real plugins on disk, one providing a service and one requiring it,
* driven through the actual PluginLoader.
*
* The ids are the real first-party ones because the in-process allowlist is
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
* does. The service ("testplugin.greet") and the permission gating it
* The service ("testplugin.greet") and the permission gating it
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
* ssh-terminal or docker plugins is under test here; only their ids are
* borrowed to reach the in-process tier.
* plugins is under test here even though the fixtures borrow their ids.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -51,8 +48,6 @@ vi.mock("../../utils/permission-manager.js", () => ({
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { TRANSPORT_OWNER_CAPABILITY } =
await import("../../plugins/first-party.js");
const {
clearServiceRegistry,
getRegistration,
@@ -99,7 +94,7 @@ export async function activate(ctx) {
function providerFixture(): Fixture {
return createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
manifestOverrides: {
category: "Terminal",
provides: [
@@ -119,7 +114,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: "docker",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
@@ -212,7 +207,7 @@ describe("plugin service contracts", () => {
await expect(loader.activate(plugin.id)).rejects.toThrow(
/no active plugin provides|provides 1\.2\.0/,
);
expect(plugin.state).toBe("crashed");
expect(plugin.state).toBe("failed");
});
it("activates anyway when an unsatisfied requirement is optional", async () => {
@@ -323,7 +318,7 @@ describe("plugin service contracts", () => {
it("refuses to provide a service the manifest never declared", async () => {
provider = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
capabilities: ["kv:own"],
backendSource: `
export async function activate(ctx) {
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
+146
View File
@@ -0,0 +1,146 @@
/**
* Core owns shutdown.
*
* gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every
* plugin's deactivate and disposes what it registered. A plugin does not need
* its own process signal handler, and must not have one: the docker console
* used to install a SIGTERM listener that called process.exit(0), which
* skipped the rest of the sequence including other plugins and the database
* flush.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
const CONSOLE_PORT = 39109;
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function isListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ port, host: "127.0.0.1" });
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
/**
* Shaped like the docker plugin: a REST server and a console WebSocket
* server, both started inside activate and closed through disposables.
*/
const DOCKER_LIKE = `
import net from "node:net";
export async function activate(ctx) {
const console = net.createServer();
await new Promise((resolve) =>
console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve),
);
ctx.disposables.add(
() => new Promise((resolve) => console.close(() => resolve())),
);
}
`;
describe("shutdownPlugins closes what plugins own", () => {
it("closes a console-style WebSocket server on shutdown", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "docker",
root: bundled,
backendSource: DOCKER_LIKE,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activate("docker");
expect(await isListening(CONSOLE_PORT)).toBe(true);
await loader.shutdown();
expect(await isListening(CONSOLE_PORT)).toBe(false);
});
// One plugin failing to shut down cleanly must not strand the others,
// which is the other half of why core drives this rather than each plugin.
it("keeps going when one plugin's deactivate throws", async () => {
const marker = path.join(tempRoot("termix-marker-"), "stopped.txt");
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: `
export async function activate() {}
export async function deactivate() {
throw new Error("alpha refuses to stop");
}
`,
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n");
}
`,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
expect(loader.get("alpha")?.state).toBe("stopped");
expect(loader.get("beta")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n");
});
});
@@ -1,121 +0,0 @@
/**
* Worker plugins gained an optional deactivate() hook. It is best-effort by
* design: a plugin that ignores it or hangs must not be able to block a
* disable, it just loses its cleanup.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
function buildLoader(broker: PluginBroker): PluginLoader {
return new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
if (runtime) await broker.requestDeactivate(runtime, 2000);
},
});
}
function newBroker(): PluginBroker {
return new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
});
}
describe("worker deactivate hook", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
});
it("runs the plugin's deactivate() before the worker is terminated", async () => {
fixture = createFixturePlugin();
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.writeFileSync(${JSON.stringify(marker)}, "cleaned");
}
`,
);
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned");
});
it("still stops a plugin that exports no deactivate", async () => {
fixture = createFixturePlugin();
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.worker).toBeNull();
});
it("does not let a hanging deactivate block the disable", async () => {
fixture = createFixturePlugin();
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
export async function activate() {}
export function deactivate() {
return new Promise(() => {});
}
`,
);
const broker = newBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
// Short timeout so the test does not wait out the real one.
if (runtime) await broker.requestDeactivate(runtime, 150);
},
});
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
const started = Date.now();
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(Date.now() - started).toBeLessThan(2000);
});
});
+18 -8
View File
@@ -11,20 +11,30 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
}
/**
* What GET /plugins returns.
*
* The admin-only fields are absent for a caller without
* admin.plugins.manage: what a plugin may do, what it has been granted and
* why it failed are operational details the shell does not need.
*/
export interface PluginSummary {
id: string;
name: string;
version: string;
tier: string;
source: string;
enabled: boolean;
runtimeState: string;
lastError: string | null;
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
contributes: PluginContributions | null;
/** Capabilities this plugin's manifest declares it may ask for. */
permissions: string[];
/** The subset of `permissions` an admin has actually granted. */
grantedCapabilities: string[];
tier?: string;
source?: string;
/** Capabilities this plugin's manifest declares. Admins only. */
capabilities?: string[];
/** The subset of `capabilities` actually granted. Admins only. */
grantedCapabilities?: string[];
/** Why the plugin is blocked or failed. Admins only. */
lastError?: string | null;
}
export async function getPlugins(): Promise<PluginSummary[]> {
+89 -25
View File
@@ -3988,31 +3988,7 @@
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
"pluginRevokeFailed": "Failed to revoke the capability",
"pluginPermissionHostsRead": "Read hosts",
"pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.",
"pluginPermissionHostsWrite": "Modify hosts",
"pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.",
"pluginPermissionCredentialsUse": "Use stored credentials",
"pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.",
"pluginPermissionSshExec": "Run commands over SSH",
"pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.",
"pluginPermissionSshSftp": "Transfer files over SFTP",
"pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.",
"pluginPermissionStorageOwn": "Store its own data",
"pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.",
"pluginPermissionStorageSecrets": "Store secrets",
"pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.",
"pluginPermissionNetworkOutbound": "Make outbound network requests",
"pluginPermissionNetworkOutboundDesc": "Contact services outside this server.",
"pluginPermissionEventsRead": "Subscribe to server events",
"pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.",
"pluginPermissionNotifySend": "Send notifications",
"pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.",
"pluginPermissionUsersRead": "Read user accounts",
"pluginPermissionUsersReadDesc": "See basic details about accounts on this server.",
"pluginPermissionProcessSidecar": "Run a background process",
"pluginPermissionProcessSidecarDesc": "Start and manage its own helper process."
"pluginRevokeFailed": "Failed to revoke the capability"
},
"newUi": {
"sidebar": {
@@ -5136,5 +5112,93 @@
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"plugins": {
"capabilities": {
"credentials:read": {
"title": "See your passwords and keys",
"consequence": "It can read the stored secret for any host you can reach."
},
"ssh:connect": {
"title": "Run commands on your servers",
"consequence": "Any command, on hosts you connect it to, with your access."
},
"process:spawn": {
"title": "Run programs on this machine",
"consequence": "Programs run on the Termix server itself, not on your hosts."
},
"users:write": {
"title": "Create and change user accounts",
"consequence": "It can add users and change their roles."
},
"auth:provide": {
"title": "Handle sign in",
"consequence": "It can add a login method or a second factor."
},
"system:tls": {
"title": "Manage certificates",
"consequence": "It can request and replace the server certificate."
},
"hosts:write": {
"title": "Create and change hosts",
"consequence": "It can add hosts and edit the ones you already have."
},
"credentials:use": {
"title": "Connect to your servers",
"consequence": "It cannot see your passwords or keys."
},
"network:outbound": {
"title": "Reach the internet",
"consequence": "It can send requests to outside services."
},
"network:serve": {
"title": "Accept connections",
"consequence": "It opens a port on the Termix server."
},
"users:read": {
"title": "See usernames and roles",
"consequence": "Never password hashes, never two factor state."
},
"events:core": {
"title": "Watch everything happening",
"consequence": "Including activity from other users, not just yours."
},
"notify:send": {
"title": "Send notifications",
"consequence": "Through the channels you have already set up."
},
"audit:read": {
"title": "Read the audit log",
"consequence": "It can see who did what and when."
},
"desktop:window": {
"title": "Open desktop windows",
"consequence": "Only in the Termix desktop app."
},
"hosts:read": {
"title": "See your host list",
"consequence": "Names and addresses for hosts you can already see."
},
"db:own": {
"title": "Store its own data",
"consequence": "Kept in its own tables, separate from other plugins."
},
"kv:own": {
"title": "Remember small settings",
"consequence": "Kept separate from other plugins."
},
"secrets:own": {
"title": "Store its own secrets",
"consequence": "Encrypted by Termix. The plugin never holds the key."
},
"settings:read-core": {
"title": "Read Termix settings",
"consequence": "Server settings, not your hosts or credentials."
},
"ui:surface": {
"title": "Add its own screens",
"consequence": "Tabs, panels and settings you can hide later."
}
}
}
}
+3 -5
View File
@@ -81,18 +81,16 @@ export function AdminPluginsSection({
key={plugin.id}
label={plugin.name}
badge={
plugin.tier === "first-party"
? t("admin.pluginBuiltIn")
: undefined
plugin.tier === "bundled" ? t("admin.pluginBuiltIn") : undefined
}
description={
plugin.lastError
? plugin.lastError
: `v${plugin.version} · ${plugin.runtimeState}`
: `v${plugin.version} · ${plugin.state}`
}
>
<div className="flex items-center gap-2">
{plugin.permissions.length > 0 && (
{(plugin.capabilities?.length ?? 0) > 0 && (
<Button
variant="outline"
size="sm"
+12 -76
View File
@@ -15,83 +15,18 @@ import {
type PluginSummary,
} from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
import { CAPABILITY_CATALOG } from "@termix/plugin-sdk/capabilities";
/**
* Every permission a plugin's manifest can declare, with the label and
* one-line description shown here. process:transport-owner is left out on
* purpose: it is reserved for first-party plugins and enforced by a hardcoded
* allowlist, not something an admin grants, so showing a toggle for it would
* be misleading. ui.* permissions describe what a plugin contributes to the
* shell rather than a capability the broker gates, but they are still shown
* here since the manifest can declare them and a plugin's grant list should
* not silently hide part of what it asked for.
* The catalog is the list. It is ordered worst first, so the riskiest thing a
* plugin asked for is the first row an admin reads rather than something they
* have to scroll for.
*/
const LABELED_PERMISSIONS: Array<{
capability: string;
labelKey: string;
descriptionKey: string;
}> = [
{
capability: "hosts.read",
labelKey: "admin.pluginPermissionHostsRead",
descriptionKey: "admin.pluginPermissionHostsReadDesc",
},
{
capability: "hosts.write",
labelKey: "admin.pluginPermissionHostsWrite",
descriptionKey: "admin.pluginPermissionHostsWriteDesc",
},
{
capability: "credentials.use",
labelKey: "admin.pluginPermissionCredentialsUse",
descriptionKey: "admin.pluginPermissionCredentialsUseDesc",
},
{
capability: "ssh.exec",
labelKey: "admin.pluginPermissionSshExec",
descriptionKey: "admin.pluginPermissionSshExecDesc",
},
{
capability: "ssh.sftp",
labelKey: "admin.pluginPermissionSshSftp",
descriptionKey: "admin.pluginPermissionSshSftpDesc",
},
{
capability: "storage.own",
labelKey: "admin.pluginPermissionStorageOwn",
descriptionKey: "admin.pluginPermissionStorageOwnDesc",
},
{
capability: "storage.secrets",
labelKey: "admin.pluginPermissionStorageSecrets",
descriptionKey: "admin.pluginPermissionStorageSecretsDesc",
},
{
capability: "network.outbound",
labelKey: "admin.pluginPermissionNetworkOutbound",
descriptionKey: "admin.pluginPermissionNetworkOutboundDesc",
},
{
capability: "events.read",
labelKey: "admin.pluginPermissionEventsRead",
descriptionKey: "admin.pluginPermissionEventsReadDesc",
},
{
capability: "notify.send",
labelKey: "admin.pluginPermissionNotifySend",
descriptionKey: "admin.pluginPermissionNotifySendDesc",
},
{
capability: "users.read",
labelKey: "admin.pluginPermissionUsersRead",
descriptionKey: "admin.pluginPermissionUsersReadDesc",
},
{
capability: "process.sidecar",
labelKey: "admin.pluginPermissionProcessSidecar",
descriptionKey: "admin.pluginPermissionProcessSidecarDesc",
},
];
const LABELED_PERMISSIONS = CAPABILITY_CATALOG.map((capability) => ({
capability: capability.id,
labelKey: capability.titleKey,
descriptionKey: capability.consequenceKey,
}));
export function PluginPermissionsDialog({
plugin,
@@ -109,10 +44,11 @@ export function PluginPermissionsDialog({
if (!plugin) return null;
const declared = plugin.capabilities ?? [];
const rows = LABELED_PERMISSIONS.filter((row) =>
plugin.permissions.includes(row.capability),
declared.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities);
const granted = new Set(plugin.grantedCapabilities ?? []);
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
+3 -3
View File
@@ -28,10 +28,10 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
id: "ssh-terminal",
name: "SSH Terminal",
version: "1.0.0",
tier: "first-party",
tier: "bundled",
source: "bundled",
enabled: true,
runtimeState: "active",
state: "active",
lastError: null,
contributes: {
tabs: [
@@ -43,7 +43,7 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
},
],
},
permissions: [],
capabilities: [],
grantedCapabilities: [],
...overrides,
};