Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
712 lines
28 KiB
JavaScript
712 lines
28 KiB
JavaScript
"use strict";
|
|
|
|
const { randomUUID } = require("node:crypto");
|
|
const path = require("node:path");
|
|
const { pathToFileURL } = require("node:url");
|
|
|
|
const { BrowserPluginRuntime } = require("./browserPluginRuntime.cjs");
|
|
const {
|
|
PLUGIN_API_VERSION,
|
|
PLUGIN_CRASH_QUARANTINE_THRESHOLD,
|
|
PLUGIN_CRASH_WINDOW_MS,
|
|
} = require("./constants.cjs");
|
|
const { PluginLogger } = require("./pluginLogger.cjs");
|
|
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
|
const {
|
|
assertHostMethod,
|
|
createDefaultPluginHostRpcRegistry,
|
|
} = require("./hostRpcRegistry.cjs");
|
|
const {
|
|
assertSecurityPrincipal,
|
|
defaultSecurityPrincipal,
|
|
} = require("./permissionResources.cjs");
|
|
const {
|
|
PLUGIN_CONTAINMENT_ERROR_CODE,
|
|
UtilityPluginRuntime,
|
|
} = require("./utilityPluginRuntime.cjs");
|
|
|
|
function assertStorageParams(params, options = {}) {
|
|
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
throw new TypeError("Plugin storage parameters must be an object");
|
|
}
|
|
if (typeof params.key !== "string" || params.key.length < 1 || params.key.length > 256 || params.key.includes("\0")) {
|
|
throw new TypeError("Plugin storage key is invalid");
|
|
}
|
|
if (options.value && !Object.hasOwn(params, "value")) throw new TypeError("Plugin storage value is required");
|
|
return params;
|
|
}
|
|
|
|
function freezeJson(value) {
|
|
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
for (const item of Array.isArray(value) ? value : Object.values(value)) freezeJson(item);
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
function resolveDefaultRuntimeKind({ plugin }) {
|
|
const requiresPrivilegedTerminalPipeline = plugin.manifest.contributes?.providers?.some((provider) => (
|
|
provider.kind === "terminal.interceptor.input"
|
|
|| provider.kind === "terminal.interceptor.output"
|
|
));
|
|
if (plugin.manifest.companionExecutables?.length || requiresPrivilegedTerminalPipeline) {
|
|
return "utility";
|
|
}
|
|
return plugin.manifest.main.browser ? "browser" : "utility";
|
|
}
|
|
|
|
class RuntimeSupervisor {
|
|
constructor(options) {
|
|
this.electron = options.electron;
|
|
this.database = options.database;
|
|
this.packageStore = options.packageStore;
|
|
this.protocol = options.protocol;
|
|
this.paths = options.paths;
|
|
this.netcattyVersion = options.netcattyVersion;
|
|
this.apiVersion = options.apiVersion ?? PLUGIN_API_VERSION;
|
|
this.supportedFeatures = [...(options.supportedFeatures ?? [])];
|
|
this.runtimeDirectory = options.runtimeDirectory;
|
|
this.appRoot = options.appRoot;
|
|
this.rpcRegistry = options.rpcRegistry ?? createDefaultPluginHostRpcRegistry({
|
|
assertStorageParams,
|
|
database: this.database,
|
|
});
|
|
this.runtimeMessageGuard = options.runtimeMessageGuard ?? null;
|
|
if (this.runtimeMessageGuard != null && typeof this.runtimeMessageGuard !== "function") {
|
|
throw new TypeError("Plugin runtime message guard must be a function");
|
|
}
|
|
this.getInitialEnvironment = options.getInitialEnvironment ?? (() => null);
|
|
if (typeof this.getInitialEnvironment !== "function") {
|
|
throw new TypeError("Plugin initial-environment provider must be a function");
|
|
}
|
|
this.resolveRuntimeKind = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
|
|
this.resolveSecurityPrincipal = options.resolveSecurityPrincipal
|
|
?? (({ plugin }) => defaultSecurityPrincipal(plugin.manifest, plugin.archiveSha256));
|
|
if (typeof this.resolveRuntimeKind !== "function" || typeof this.resolveSecurityPrincipal !== "function") {
|
|
throw new TypeError("Plugin runtime placement and security-principal resolvers must be functions");
|
|
}
|
|
this.utilityModuleMappings = options.utilityModuleMappings ?? {
|
|
"@netcatty/plugin-sdk": pathToFileURL(path.join(
|
|
this.appRoot, "node_modules", "@netcatty", "plugin-sdk", "dist", "index.js",
|
|
)).href,
|
|
"@netcatty/plugin-contract": pathToFileURL(path.join(
|
|
this.appRoot, "node_modules", "@netcatty", "plugin-contract", "dist", "index.js",
|
|
)).href,
|
|
};
|
|
this.runtimeFactories = options.runtimeFactories ?? {
|
|
browser: (runtimeOptions) => new BrowserPluginRuntime(runtimeOptions),
|
|
utility: (runtimeOptions) => new UtilityPluginRuntime(runtimeOptions),
|
|
};
|
|
this.runtimeResourceMonitor = options.runtimeResourceMonitor ?? null;
|
|
this.runtimeCleanup = options.runtimeCleanup ?? null;
|
|
if (this.runtimeCleanup != null && typeof this.runtimeCleanup !== "function") {
|
|
throw new TypeError("Plugin runtime cleanup must be a function");
|
|
}
|
|
this.resourceMonitors = new Map();
|
|
this.runtimes = new Map();
|
|
this.runtimeIdentities = new Map();
|
|
this.starting = new Map();
|
|
this.startControllers = new Map();
|
|
this.stopping = new Map();
|
|
this.runtimeListeners = new Set();
|
|
this.progressListeners = new Set();
|
|
this.uncontainedPlugins = new Set();
|
|
this.shuttingDown = false;
|
|
this.shutdownPromise = null;
|
|
}
|
|
|
|
onDidChangeRuntime(listener) {
|
|
if (typeof listener !== "function") throw new TypeError("Plugin runtime listener must be a function");
|
|
this.runtimeListeners.add(listener);
|
|
let disposed = false;
|
|
return Object.freeze({
|
|
dispose: () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
this.runtimeListeners.delete(listener);
|
|
},
|
|
});
|
|
}
|
|
|
|
onDidReportProgress(listener) {
|
|
if (typeof listener !== "function") throw new TypeError("Plugin progress listener must be a function");
|
|
this.progressListeners.add(listener);
|
|
let disposed = false;
|
|
return Object.freeze({
|
|
dispose: () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
this.progressListeners.delete(listener);
|
|
},
|
|
});
|
|
}
|
|
|
|
#emitProgress(identity, params) {
|
|
identity.assertCurrent();
|
|
const event = Object.freeze({
|
|
type: "runtime-progress",
|
|
pluginId: identity.pluginId,
|
|
pluginVersion: identity.pluginVersion,
|
|
runtimeId: identity.runtimeId,
|
|
runtimeKind: identity.runtimeKind,
|
|
securityPrincipal: identity.securityPrincipal,
|
|
token: params.token,
|
|
value: freezeJson(structuredClone(params.value)),
|
|
});
|
|
for (const listener of [...this.progressListeners]) {
|
|
try { listener(event); } catch {}
|
|
}
|
|
}
|
|
|
|
#emitRuntimeState(identity, status, details = {}) {
|
|
const event = Object.freeze({
|
|
type: "runtime-state",
|
|
pluginId: identity?.pluginId ?? details.pluginId,
|
|
pluginVersion: identity?.pluginVersion ?? details.pluginVersion ?? null,
|
|
runtimeId: identity?.runtimeId ?? null,
|
|
runtimeKind: identity?.runtimeKind ?? details.kind ?? null,
|
|
securityPrincipal: identity?.securityPrincipal ?? null,
|
|
status,
|
|
error: details.error == null ? null : String(details.error),
|
|
quarantinedAt: details.quarantinedAt ?? null,
|
|
});
|
|
for (const listener of [...this.runtimeListeners]) {
|
|
try { listener(event); } catch {}
|
|
}
|
|
}
|
|
|
|
#snapshotInitialEnvironment(identity) {
|
|
identity.assertCurrent();
|
|
const providedEnvironment = this.getInitialEnvironment(Object.freeze({
|
|
pluginId: identity.pluginId,
|
|
pluginVersion: identity.pluginVersion,
|
|
runtimeId: identity.runtimeId,
|
|
runtimeKind: identity.runtimeKind,
|
|
}));
|
|
if (providedEnvironment && typeof providedEnvironment.then === "function") {
|
|
throw new TypeError("Plugin initial-environment provider must be synchronous");
|
|
}
|
|
if (providedEnvironment != null
|
|
&& (typeof providedEnvironment !== "object" || Array.isArray(providedEnvironment))) {
|
|
throw new TypeError("Plugin initial environment must be an object or null");
|
|
}
|
|
return providedEnvironment == null
|
|
? null
|
|
: freezeJson(structuredClone(providedEnvironment));
|
|
}
|
|
|
|
#isInstalledVersion(identity) {
|
|
return Boolean(identity && this.database.getVersion(identity.pluginId, identity.pluginVersion));
|
|
}
|
|
|
|
#setRuntimeState(identity, status, details = {}) {
|
|
const pluginId = identity?.pluginId ?? details.pluginId;
|
|
if (!identity || this.#isInstalledVersion(identity)) {
|
|
this.database.setRuntimeState(pluginId, status, {
|
|
pluginVersion: identity?.pluginVersion ?? details.pluginVersion,
|
|
kind: identity?.runtimeKind ?? details.kind,
|
|
error: details.error,
|
|
quarantinedAt: details.quarantinedAt,
|
|
});
|
|
}
|
|
this.#emitRuntimeState(identity, status, details);
|
|
}
|
|
|
|
async startEnabled() {
|
|
for (const plugin of this.database.listPlugins()) {
|
|
if (!plugin.enabled || plugin.runtime.quarantinedAt != null) continue;
|
|
try { await this.start(plugin.id); } catch {}
|
|
}
|
|
}
|
|
|
|
async start(pluginId) {
|
|
if (this.shuttingDown) throw new Error("Plugin runtime supervisor is shutting down");
|
|
if (this.uncontainedPlugins.has(pluginId)) {
|
|
throw this.#restoreContainmentBlock(pluginId);
|
|
}
|
|
const stopping = this.stopping.get(pluginId);
|
|
if (stopping) await stopping;
|
|
if (this.shuttingDown) throw new Error("Plugin runtime supervisor is shutting down");
|
|
if (this.starting.has(pluginId)) return this.starting.get(pluginId);
|
|
if (this.runtimes.has(pluginId)) {
|
|
await this.#getRunningRuntime(pluginId);
|
|
return this.getRuntimeIdentity(pluginId);
|
|
}
|
|
const controller = new AbortController();
|
|
const promise = this.#start(pluginId, controller.signal).finally(() => {
|
|
this.starting.delete(pluginId);
|
|
this.startControllers.delete(pluginId);
|
|
});
|
|
this.starting.set(pluginId, promise);
|
|
this.startControllers.set(pluginId, controller);
|
|
return promise;
|
|
}
|
|
|
|
async #start(pluginId, signal) {
|
|
signal.throwIfAborted();
|
|
const plugin = this.database.getActivePlugin(pluginId);
|
|
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
|
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
|
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
|
const pluginCli = await import("@netcatty/plugin-cli");
|
|
signal.throwIfAborted();
|
|
const compatibility = pluginCli.checkPluginCompatibility(plugin.manifest, {
|
|
netcattyVersion: this.netcattyVersion,
|
|
apiVersion: this.apiVersion,
|
|
features: this.supportedFeatures,
|
|
});
|
|
if (!compatibility.compatible) throw new Error(`Plugin is incompatible: ${compatibility.errors.join("; ")}`);
|
|
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
|
signal.throwIfAborted();
|
|
const availableKinds = Object.freeze([
|
|
...(plugin.manifest.main.browser ? ["browser"] : []),
|
|
...(plugin.manifest.main.node ? ["utility"] : []),
|
|
]);
|
|
const placementPlugin = freezeJson(structuredClone(plugin));
|
|
const securityPrincipal = assertSecurityPrincipal(await raceWithAbort(Promise.resolve(
|
|
this.resolveSecurityPrincipal(Object.freeze({ plugin: placementPlugin, signal })),
|
|
), signal));
|
|
const kind = await raceWithAbort(Promise.resolve(this.resolveRuntimeKind(Object.freeze({
|
|
plugin: placementPlugin,
|
|
availableKinds,
|
|
securityPrincipal,
|
|
signal,
|
|
}))), signal);
|
|
signal.throwIfAborted();
|
|
if ((kind !== "browser" && kind !== "utility") || !availableKinds.includes(kind)) {
|
|
throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
|
}
|
|
const currentPlugin = this.database.getActivePlugin(pluginId);
|
|
if (
|
|
!currentPlugin?.enabled
|
|
|| currentPlugin.activeVersion !== plugin.activeVersion
|
|
|| currentPlugin.runtime.quarantinedAt != null
|
|
) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
|
|
}
|
|
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
|
|
let runtime;
|
|
const identity = Object.freeze({
|
|
pluginId,
|
|
pluginVersion: plugin.activeVersion,
|
|
runtimeId: randomUUID(),
|
|
runtimeKind: kind,
|
|
securityPrincipal,
|
|
manifest: freezeJson(structuredClone(plugin.manifest)),
|
|
packageRoot,
|
|
logger,
|
|
assertCurrent: () => {
|
|
const active = this.database.getActivePlugin(pluginId);
|
|
if (
|
|
this.runtimes.get(pluginId) !== runtime
|
|
|| !active?.enabled
|
|
|| active.activeVersion !== plugin.activeVersion
|
|
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
|
|
) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime identity is stale or inactive");
|
|
}
|
|
},
|
|
});
|
|
const routes = this.rpcRegistry.createRoutes(identity);
|
|
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
|
|
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
|
|
error: error?.message ?? String(error),
|
|
});
|
|
runtime = kind === "browser"
|
|
? this.runtimeFactories.browser({
|
|
electron: this.electron,
|
|
protocol: this.protocol,
|
|
plugin,
|
|
packageRoot,
|
|
preloadPath: path.join(this.runtimeDirectory, "browserPreload.cjs"),
|
|
requestHandlers: routes.requestHandlers,
|
|
notificationHandlers: routes.notificationHandlers,
|
|
onBeforeMessage: this.runtimeMessageGuard
|
|
? (message) => this.runtimeMessageGuard(identity, message)
|
|
: undefined,
|
|
onIncomingStream: routes.onIncomingStream,
|
|
onProgress: (params) => this.#emitProgress(identity, params),
|
|
onProcessReady: () => this.#installRuntimeResourceMonitor(identity, runtime),
|
|
logger,
|
|
onExit,
|
|
onProtocolError,
|
|
})
|
|
: this.runtimeFactories.utility({
|
|
utilityProcess: this.electron.utilityProcess,
|
|
plugin,
|
|
packageRoot,
|
|
bootstrapPath: path.join(this.runtimeDirectory, "utilityRuntime.mjs"),
|
|
moduleMappings: this.utilityModuleMappings,
|
|
requestHandlers: routes.requestHandlers,
|
|
notificationHandlers: routes.notificationHandlers,
|
|
onBeforeMessage: this.runtimeMessageGuard
|
|
? (message) => this.runtimeMessageGuard(identity, message)
|
|
: undefined,
|
|
onIncomingStream: routes.onIncomingStream,
|
|
onProgress: (params) => this.#emitProgress(identity, params),
|
|
onProcessReady: () => this.#installRuntimeResourceMonitor(identity, runtime),
|
|
logger,
|
|
onExit,
|
|
onProtocolError,
|
|
});
|
|
this.runtimes.set(pluginId, runtime);
|
|
this.runtimeIdentities.set(pluginId, identity);
|
|
this.#setRuntimeState(identity, "starting");
|
|
try {
|
|
const environment = this.#snapshotInitialEnvironment(identity);
|
|
const initialized = await raceWithAbort(Promise.resolve(runtime.start({
|
|
pluginId,
|
|
pluginVersion: plugin.activeVersion,
|
|
netcattyVersion: this.netcattyVersion,
|
|
apiVersion: this.apiVersion,
|
|
supportedFeatures: this.supportedFeatures,
|
|
enabledFeatures: compatibility.enabledFeatures,
|
|
environment,
|
|
}, {
|
|
signal,
|
|
getActivationEnvironment: () => this.#snapshotInitialEnvironment(identity),
|
|
})), signal);
|
|
if (
|
|
initialized.pluginId !== pluginId
|
|
|| initialized.pluginVersion !== plugin.activeVersion
|
|
|| initialized.apiVersion !== this.apiVersion
|
|
|| JSON.stringify([...initialized.enabledFeatures].sort())
|
|
!== JSON.stringify([...compatibility.enabledFeatures].sort())
|
|
) {
|
|
throw new Error("Plugin initialization identity or feature negotiation mismatch");
|
|
}
|
|
identity.assertCurrent();
|
|
this.#setRuntimeState(identity, "running");
|
|
this.#installRuntimeResourceMonitor(identity, runtime);
|
|
return this.getRuntimeIdentity(pluginId);
|
|
} catch (error) {
|
|
const stillOwned = this.runtimes.get(pluginId) === runtime;
|
|
let stopError;
|
|
let cleanupError;
|
|
if (stillOwned) {
|
|
this.runtimes.delete(pluginId);
|
|
this.runtimeIdentities.delete(pluginId);
|
|
this.#releaseRuntimeResources(identity);
|
|
try { await runtime.stop(); } catch (caughtError) { stopError = caughtError; }
|
|
try { await this.#cleanupRuntime(identity); } catch (caughtError) { cleanupError = caughtError; }
|
|
}
|
|
const containmentError = cleanupError
|
|
?? (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE ? stopError : null);
|
|
if (stillOwned && containmentError) {
|
|
this.#recordContainmentFailure(identity, containmentError);
|
|
throw containmentError;
|
|
}
|
|
if (stillOwned) await this.#recordFailure(identity, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#restoreContainmentBlock(pluginId) {
|
|
const error = new PluginRpcError(
|
|
RPC_ERRORS.unavailable,
|
|
`Plugin runtime containment failed; restart Netcatty before starting it again: ${pluginId}`,
|
|
);
|
|
const active = this.database.getActivePlugin(pluginId);
|
|
if (active?.activeVersion) {
|
|
if (active.enabled) this.database.setEnabled(pluginId, false);
|
|
this.#setRuntimeState(null, "quarantined", {
|
|
pluginId,
|
|
pluginVersion: active.activeVersion,
|
|
kind: active.runtime.kind,
|
|
error: active.runtime.lastError ?? error.message,
|
|
quarantinedAt: active.runtime.quarantinedAt ?? Date.now(),
|
|
});
|
|
}
|
|
return error;
|
|
}
|
|
|
|
#recordContainmentFailure(identity, error) {
|
|
this.uncontainedPlugins.add(identity.pluginId);
|
|
const active = this.database.getActivePlugin(identity.pluginId);
|
|
if (active?.activeVersion === identity.pluginVersion && active.enabled) {
|
|
this.database.setEnabled(identity.pluginId, false);
|
|
}
|
|
this.#setRuntimeState(identity, "quarantined", {
|
|
error: error?.message ?? String(error),
|
|
quarantinedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
async #handleExit(pluginId, runtime, identity, details) {
|
|
if (this.runtimes.get(pluginId) !== runtime) return;
|
|
this.runtimes.delete(pluginId);
|
|
this.runtimeIdentities.delete(pluginId);
|
|
this.#releaseRuntimeResources(identity);
|
|
try {
|
|
await this.#cleanupRuntime(identity);
|
|
} catch (error) {
|
|
this.#recordContainmentFailure(identity, error);
|
|
return;
|
|
}
|
|
if (details.containmentFailed) {
|
|
this.#recordContainmentFailure(identity, details.error);
|
|
return;
|
|
}
|
|
if (details.expected || this.shuttingDown) {
|
|
this.#setRuntimeState(identity, "stopped");
|
|
return;
|
|
}
|
|
await this.#recordFailure(identity, details.error);
|
|
}
|
|
|
|
async #recordFailure(identity, error) {
|
|
const pluginId = identity.pluginId;
|
|
if (!this.#isInstalledVersion(identity)) {
|
|
this.#emitRuntimeState(identity, "error", {
|
|
error: error?.message ?? String(error),
|
|
});
|
|
return;
|
|
}
|
|
const crash = this.database.recordCrash(
|
|
pluginId,
|
|
identity.pluginVersion,
|
|
PLUGIN_CRASH_WINDOW_MS,
|
|
PLUGIN_CRASH_QUARANTINE_THRESHOLD,
|
|
);
|
|
this.#setRuntimeState(identity, crash.quarantined ? "quarantined" : "error", {
|
|
error: error?.message ?? String(error),
|
|
quarantinedAt: crash.quarantinedAt,
|
|
});
|
|
}
|
|
|
|
stop(pluginId) {
|
|
if (this.stopping.has(pluginId)) return this.stopping.get(pluginId);
|
|
const promise = this.#stop(pluginId).finally(() => this.stopping.delete(pluginId));
|
|
this.stopping.set(pluginId, promise);
|
|
return promise;
|
|
}
|
|
|
|
async #stop(pluginId) {
|
|
const starting = this.starting.get(pluginId);
|
|
this.startControllers.get(pluginId)?.abort(new PluginRpcError(
|
|
RPC_ERRORS.cancelled,
|
|
`Plugin startup was stopped: ${pluginId}`,
|
|
));
|
|
const plugin = this.database.getActivePlugin(pluginId);
|
|
const runtime = this.runtimes.get(pluginId);
|
|
const identity = this.runtimeIdentities.get(pluginId);
|
|
if (!runtime) {
|
|
if (starting) {
|
|
try { await starting; } catch {}
|
|
}
|
|
if (plugin && plugin.runtime.quarantinedAt == null && plugin.runtime.status !== "stopped") {
|
|
this.#setRuntimeState(identity, "stopped", {
|
|
pluginId,
|
|
pluginVersion: plugin.activeVersion,
|
|
kind: plugin.runtime.kind,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
this.runtimes.delete(pluginId);
|
|
this.runtimeIdentities.delete(pluginId);
|
|
this.#releaseRuntimeResources(identity);
|
|
let stopError;
|
|
let cleanupError;
|
|
try {
|
|
await runtime.stop();
|
|
} catch (error) {
|
|
stopError = error;
|
|
}
|
|
try {
|
|
await this.#cleanupRuntime(identity);
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
} finally {
|
|
const containmentError = cleanupError
|
|
?? (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE ? stopError : null);
|
|
if (containmentError) {
|
|
this.#recordContainmentFailure(identity, containmentError);
|
|
} else {
|
|
this.#setRuntimeState(identity, "stopped", {
|
|
kind: plugin?.runtime?.kind,
|
|
error: stopError?.message,
|
|
});
|
|
}
|
|
}
|
|
if (cleanupError) throw cleanupError;
|
|
if (stopError?.code === PLUGIN_CONTAINMENT_ERROR_CODE) throw stopError;
|
|
}
|
|
|
|
async restart(pluginId) {
|
|
await this.stop(pluginId);
|
|
this.database.clearQuarantine(pluginId);
|
|
return this.start(pluginId);
|
|
}
|
|
|
|
#releaseRuntimeResources(identity) {
|
|
if (!identity) return;
|
|
this.resourceMonitors.get(identity.runtimeId)?.dispose?.();
|
|
this.resourceMonitors.delete(identity.runtimeId);
|
|
this.runtimeResourceMonitor?.releaseRuntime?.(identity.runtimeId);
|
|
}
|
|
|
|
async #cleanupRuntime(identity) {
|
|
if (!identity || !this.runtimeCleanup) return;
|
|
await this.runtimeCleanup(Object.freeze({
|
|
pluginId: identity.pluginId,
|
|
pluginVersion: identity.pluginVersion,
|
|
runtimeId: identity.runtimeId,
|
|
runtimeKind: identity.runtimeKind,
|
|
securityPrincipal: identity.securityPrincipal,
|
|
}));
|
|
}
|
|
|
|
#installRuntimeResourceMonitor(identity, runtime) {
|
|
if (!this.runtimeResourceMonitor || this.resourceMonitors.has(identity.runtimeId)) return;
|
|
const monitor = this.runtimeResourceMonitor.trackRuntime(identity, runtime);
|
|
this.resourceMonitors.set(identity.runtimeId, monitor);
|
|
}
|
|
|
|
async enforcePolicyViolation(identity, error) {
|
|
const current = this.runtimeIdentities.get(identity.pluginId);
|
|
if (!current || current.runtimeId !== identity.runtimeId) return;
|
|
const plugin = this.database.getActivePlugin(identity.pluginId);
|
|
if (plugin?.enabled) this.database.setEnabled(identity.pluginId, false);
|
|
await this.stop(identity.pluginId);
|
|
this.#setRuntimeState(identity, "error", { error: error?.message ?? String(error) });
|
|
}
|
|
|
|
getRuntimeIdentity(pluginId) {
|
|
const identity = this.runtimeIdentities.get(pluginId);
|
|
if (!identity) return null;
|
|
return Object.freeze({
|
|
pluginId: identity.pluginId,
|
|
pluginVersion: identity.pluginVersion,
|
|
runtimeId: identity.runtimeId,
|
|
runtimeKind: identity.runtimeKind,
|
|
securityPrincipal: identity.securityPrincipal,
|
|
});
|
|
}
|
|
|
|
#assertExpectedRuntimeIdentity(identity, expectedIdentity) {
|
|
if (!expectedIdentity) return;
|
|
if (
|
|
identity.pluginId !== expectedIdentity.pluginId
|
|
|| identity.pluginVersion !== expectedIdentity.pluginVersion
|
|
|| identity.runtimeId !== expectedIdentity.runtimeId
|
|
|| identity.runtimeKind !== expectedIdentity.runtimeKind
|
|
|| identity.securityPrincipal !== expectedIdentity.securityPrincipal
|
|
) {
|
|
throw new PluginRpcError(
|
|
RPC_ERRORS.unavailable,
|
|
`Plugin runtime no longer matches the authorized activation: ${identity.pluginId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async #getRunningRuntime(pluginId) {
|
|
const starting = this.starting.get(pluginId);
|
|
if (starting) await starting;
|
|
const runtime = this.runtimes.get(pluginId);
|
|
const identity = this.runtimeIdentities.get(pluginId);
|
|
if (!runtime || !identity) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime is unavailable: ${pluginId}`);
|
|
}
|
|
const active = this.database.getActivePlugin(pluginId);
|
|
if (
|
|
!active?.enabled
|
|
|| active.activeVersion !== identity.pluginVersion
|
|
|| active.runtime.status !== "running"
|
|
) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime identity is stale or inactive: ${pluginId}`);
|
|
}
|
|
return { identity, runtime };
|
|
}
|
|
|
|
async request(pluginId, method, params, options) {
|
|
assertHostMethod(method);
|
|
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
|
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
|
if (typeof runtime.request !== "function") {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive requests: ${pluginId}`);
|
|
}
|
|
const runtimeOptions = options == null
|
|
? undefined
|
|
: Object.fromEntries(Object.entries(options).filter(([key]) => key !== "expectedIdentity"));
|
|
const result = await runtime.request(method, params, runtimeOptions);
|
|
const current = await this.#getRunningRuntime(pluginId);
|
|
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while handling request: ${pluginId}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async notify(pluginId, method, params, options) {
|
|
assertHostMethod(method);
|
|
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
|
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
|
if (typeof runtime.notify !== "function") {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive notifications: ${pluginId}`);
|
|
}
|
|
runtime.notify(method, params);
|
|
const current = await this.#getRunningRuntime(pluginId);
|
|
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while sending notification: ${pluginId}`);
|
|
}
|
|
}
|
|
|
|
async openStream(pluginId, streamId, windowBytes, options) {
|
|
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
|
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
|
if (typeof runtime.openStream !== "function") {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
|
|
}
|
|
const stream = await runtime.openStream(streamId, windowBytes);
|
|
try {
|
|
const current = await this.#getRunningRuntime(pluginId);
|
|
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
|
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime changed while opening stream: ${pluginId}`);
|
|
}
|
|
return stream;
|
|
} catch (error) {
|
|
try { stream?.cancel?.(); } catch {}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async attachTerminalInterceptor(pluginId, descriptor, port, options) {
|
|
const { identity, runtime } = await this.#getRunningRuntime(pluginId);
|
|
this.#assertExpectedRuntimeIdentity(identity, options?.expectedIdentity);
|
|
if (identity.runtimeKind !== "utility" || typeof runtime.attachTerminalInterceptor !== "function") {
|
|
throw new PluginRpcError(
|
|
RPC_ERRORS.failedPrecondition,
|
|
`Terminal interceptors require an advanced utility runtime: ${pluginId}`,
|
|
);
|
|
}
|
|
await runtime.attachTerminalInterceptor(descriptor, port);
|
|
}
|
|
|
|
shutdown() {
|
|
this.shutdownPromise ??= this.#shutdown();
|
|
return this.shutdownPromise;
|
|
}
|
|
|
|
async #shutdown() {
|
|
this.shuttingDown = true;
|
|
for (const [pluginId, controller] of this.startControllers) {
|
|
controller.abort(new PluginRpcError(
|
|
RPC_ERRORS.unavailable,
|
|
`Plugin runtime supervisor is shutting down: ${pluginId}`,
|
|
));
|
|
}
|
|
await Promise.allSettled([...this.runtimes.keys()].map((pluginId) => this.stop(pluginId)));
|
|
await Promise.allSettled([...this.starting.values()]);
|
|
await Promise.allSettled([...this.stopping.values()]);
|
|
for (const identity of this.runtimeIdentities.values()) this.#releaseRuntimeResources(identity);
|
|
this.resourceMonitors.clear();
|
|
this.runtimeListeners.clear();
|
|
this.progressListeners.clear();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
RuntimeSupervisor,
|
|
assertStorageParams,
|
|
freezeJson,
|
|
resolveDefaultRuntimeKind,
|
|
};
|