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

+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),
},
});
});