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:
LukeGus committed 2026-09-21 08:28:50 -05:00
1 parent e040b12045
commit c94a453f1e
43 files changed
+2426 -38

No files matched your search

+36
View File
@@ -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;
+58
View File
@@ -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)
);
}
+173
View File
@@ -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" },
);
}
}
}
+68 -1
View File
@@ -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
View File
@@ -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}`;
+20
View File
@@ -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: [] };
}
+33
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+59
View File
@@ -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();
}
+24
View File
@@ -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" });