mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
1 parent
e040b12045
commit
c94a453f1e
43 files changed
+2426
-38
No files matched your search
@@ -79,6 +79,8 @@ 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;
|
||||
@@ -605,6 +607,40 @@ export class PluginBroker {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ssh-terminal",
|
||||
]);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>;
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 = [];
|
||||
|
||||
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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { PluginBroker, type PluginRuntime } from "./broker.js";
|
||||
import { buildPluginRouter } from "./http-bridge.js";
|
||||
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||
import { isFirstParty, runsInProcess } from "./first-party.js";
|
||||
|
||||
let loader: PluginLoader | null = null;
|
||||
let broker: PluginBroker | null = null;
|
||||
@@ -129,11 +130,60 @@ export function getPluginRuntime(): {
|
||||
broker!.detach(plugin.id);
|
||||
unregisterPluginRouter(plugin.id);
|
||||
},
|
||||
onBeforeTerminate: async (plugin) => {
|
||||
const runtime = broker!.get(plugin.id);
|
||||
if (runtime) await broker!.requestDeactivate(runtime);
|
||||
},
|
||||
});
|
||||
}
|
||||
return { loader, broker };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a bundled first-party plugin has a row so it can be enabled and
|
||||
* disabled like any other.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async function seedBundledPlugins(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 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),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await repository.create({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
tier: "first-party",
|
||||
source: "bundled",
|
||||
state: "enabled",
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
|
||||
pluginLogger.info(`Registered bundled 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.
|
||||
@@ -144,6 +194,8 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
const loaded = await pluginLoader.loadAll();
|
||||
if (loaded.length === 0) return [];
|
||||
|
||||
await seedBundledPlugins(loaded);
|
||||
|
||||
const {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
@@ -156,6 +208,21 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
const record = byId.get(plugin.id);
|
||||
if (!record || record.state !== "enabled") continue;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -189,7 +256,7 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
|
||||
export async function activatePlugin(
|
||||
pluginId: string,
|
||||
ownerUserId: string,
|
||||
ownerUserId?: string,
|
||||
): Promise<void> {
|
||||
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
|
||||
await pluginLoader.activate(pluginId, ownerUserId);
|
||||
|
||||
+174
-20
@@ -41,11 +41,19 @@ import { Worker } from "node:worker_threads";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { parseManifest, type PluginManifest } from "./manifest.js";
|
||||
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,
|
||||
type PluginModule,
|
||||
} from "./host-ctx.js";
|
||||
|
||||
export type PluginState =
|
||||
| "loaded"
|
||||
@@ -68,10 +76,19 @@ export interface LoadedPlugin {
|
||||
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;
|
||||
lastError: string | null;
|
||||
/** The user whose authority this plugin acts under. Set at activate time. */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -83,6 +100,11 @@ export interface PluginLoaderOptions {
|
||||
*/
|
||||
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. */
|
||||
@@ -172,6 +194,7 @@ export class PluginLoader {
|
||||
manifest,
|
||||
state: "loaded",
|
||||
worker: null,
|
||||
inProcess: null,
|
||||
restartAttempts: 0,
|
||||
lastError: null,
|
||||
ownerUserId: null,
|
||||
@@ -184,31 +207,56 @@ export class PluginLoader {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/** Scans the plugins directory, loading every valid plugin it finds. */
|
||||
/**
|
||||
* Scans for plugins, loading every valid one it finds.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async loadAll(): Promise<LoadedPlugin[]> {
|
||||
const root = getPluginsDir();
|
||||
if (!fs.existsSync(root)) return [];
|
||||
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
const loaded: LoadedPlugin[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
try {
|
||||
loaded.push(await this.load(path.join(root, entry.name)));
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
`Skipping plugin directory ${entry.name}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_load" },
|
||||
);
|
||||
for (const root of [getBundledPluginsDir(), getPluginsDir()]) {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await this.load(path.join(root, entry.name));
|
||||
seen.add(plugin.id);
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
`Skipping plugin directory ${entry.name}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_load" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async activate(pluginId: string, ownerUserId: string): Promise<void> {
|
||||
/**
|
||||
* `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 (see broker.requireOwner).
|
||||
*/
|
||||
async activate(pluginId: string, ownerUserId?: string): Promise<void> {
|
||||
const plugin = this.requirePlugin(pluginId);
|
||||
|
||||
if (plugin.state === "active" || plugin.state === "activating") return;
|
||||
@@ -216,7 +264,7 @@ export class PluginLoader {
|
||||
throw new Error(`Plugin ${pluginId} has no backend to activate`);
|
||||
}
|
||||
|
||||
plugin.ownerUserId = ownerUserId;
|
||||
plugin.ownerUserId = ownerUserId ?? null;
|
||||
await this.spawn(plugin);
|
||||
}
|
||||
|
||||
@@ -224,6 +272,11 @@ export class PluginLoader {
|
||||
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,
|
||||
@@ -263,6 +316,61 @@ export class PluginLoader {
|
||||
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));
|
||||
|
||||
try {
|
||||
const imported = (await import(entry)) as {
|
||||
activate?: PluginModule["activate"];
|
||||
deactivate?: PluginModule["deactivate"];
|
||||
default?: PluginModule;
|
||||
};
|
||||
|
||||
const activate = imported.activate ?? imported.default?.activate;
|
||||
const deactivate = imported.deactivate ?? imported.default?.deactivate;
|
||||
|
||||
if (typeof activate !== "function") {
|
||||
throw new Error(
|
||||
`Plugin ${plugin.id} backend entry does not export an activate(ctx) function`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle: InProcessHandle = {
|
||||
module: { activate, deactivate },
|
||||
unsubscribers: [],
|
||||
providedKeys: [],
|
||||
};
|
||||
|
||||
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)`, {
|
||||
operation: "plugin_activate",
|
||||
});
|
||||
} catch (error) {
|
||||
plugin.lastError = error instanceof Error ? error.message : String(error);
|
||||
plugin.inProcess = null;
|
||||
plugin.state = "crashed";
|
||||
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(() => {
|
||||
@@ -401,7 +509,12 @@ export class PluginLoader {
|
||||
plugin.state = "loaded";
|
||||
plugin.lastError = null;
|
||||
|
||||
if (!plugin.ownerUserId) {
|
||||
// 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`,
|
||||
);
|
||||
@@ -423,7 +536,7 @@ export class PluginLoader {
|
||||
this.stabilityTimers.delete(pluginId);
|
||||
}
|
||||
|
||||
if (!plugin.worker) {
|
||||
if (!plugin.worker && !plugin.inProcess) {
|
||||
plugin.state = "stopped";
|
||||
return;
|
||||
}
|
||||
@@ -434,8 +547,29 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const worker = plugin.worker;
|
||||
if (!worker) return;
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
plugin.worker = null;
|
||||
try {
|
||||
await worker.terminate();
|
||||
@@ -476,6 +610,26 @@ function pickTempEnv(): Record<string, string> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), ms);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pathToFileUrlString(filePath: string): string {
|
||||
const resolved = path.resolve(filePath).replace(/\\/g, "/");
|
||||
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* runtime, for the same reason. If you change the schema, change both.
|
||||
*/
|
||||
|
||||
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
|
||||
|
||||
export const PLUGIN_PERMISSIONS = [
|
||||
"hosts.read",
|
||||
"hosts.write",
|
||||
@@ -24,6 +26,10 @@ export const PLUGIN_PERMISSIONS = [
|
||||
"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",
|
||||
@@ -325,5 +331,19 @@ export function parseManifest(raw: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
// 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: [] };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Where unpacked plugins live. Resolved per call rather than captured at module
|
||||
@@ -9,6 +11,37 @@ export function getPluginsDir(): string {
|
||||
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where first-party 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.
|
||||
*/
|
||||
export function getBundledPluginsDir(): string {
|
||||
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
if (override) return override;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
// Built server: dist/backend/backend/plugins -> dist/plugins
|
||||
path.resolve(here, "../../../plugins"),
|
||||
// Running from source under vitest/tsx: src/backend/plugins -> plugins
|
||||
path.resolve(here, "../../../plugins"),
|
||||
path.resolve(here, "../../../../plugins"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
export function getPluginDir(pluginId: string): string {
|
||||
return path.join(getPluginsDir(), pluginId);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export type PluginResponse = PluginResponseOk | PluginResponseErr;
|
||||
|
||||
/** Main thread -> worker, pushed rather than requested. */
|
||||
export interface PluginPush {
|
||||
kind: "event" | "http" | "timer";
|
||||
kind: "event" | "http" | "timer" | "deactivate";
|
||||
/** Event topic, route key, or timer id depending on kind. */
|
||||
key: string;
|
||||
payload: unknown;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A small main-thread service registry.
|
||||
*
|
||||
* This exists so a module can depend on a capability without depending on
|
||||
* whichever module happens to provide it. Two uses today:
|
||||
*
|
||||
* - core registers the SSH connection pool as "ssh.transport" at boot, so
|
||||
* everything that needs a pooled connection resolves a provider rather
|
||||
* than importing the pool directly.
|
||||
* - the ssh-terminal plugin publishes "terminal.sessions" so collab and
|
||||
* session-sharing can reach live sessions without importing a plugin.
|
||||
*
|
||||
* Deliberately NOT reachable from a worker plugin's ctx. Values here are live
|
||||
* objects -- pools, session managers, sockets -- and handing one across the
|
||||
* postMessage boundary is exactly what that boundary exists to prevent. Only
|
||||
* in-process first-party plugins get a reference to this.
|
||||
*
|
||||
* `consume` returns undefined rather than throwing when nothing is registered.
|
||||
* A provider can disappear when its plugin is disabled, and callers are
|
||||
* expected to handle that rather than assume it is always there.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
const providers = new Map<string, unknown>();
|
||||
|
||||
export function provide<T>(key: string, value: T): void {
|
||||
if (providers.has(key)) {
|
||||
pluginLogger.warn(
|
||||
`Service "${key}" is already registered and is being replaced`,
|
||||
{ operation: "plugin_registry" },
|
||||
);
|
||||
}
|
||||
providers.set(key, value);
|
||||
}
|
||||
|
||||
export function consume<T>(key: string): T | undefined {
|
||||
return providers.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a provider, but only if `value` is the one currently registered.
|
||||
* A plugin that crashed and restarted must not revoke the replacement its own
|
||||
* restart installed, so revocation is identity-checked rather than by key.
|
||||
*/
|
||||
export function revoke(key: string, value?: unknown): boolean {
|
||||
if (!providers.has(key)) return false;
|
||||
if (value !== undefined && providers.get(key) !== value) return false;
|
||||
return providers.delete(key);
|
||||
}
|
||||
|
||||
export function has(key: string): boolean {
|
||||
return providers.has(key);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearRegistry(): void {
|
||||
providers.clear();
|
||||
}
|
||||
@@ -60,6 +60,21 @@ function handleResponse(message: PluginResponse): void {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -218,15 +233,24 @@ const ctx = {
|
||||
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" });
|
||||
|
||||
Reference in new issue
Block a user